Coverage for src / agent_contracts / contracts.py: 88%
34 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-09 00:42 +0900
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-09 00:42 +0900
1"""NodeContract - Node I/O contracts.
3Each node defines a CONTRACT class variable to declare its
4inputs, outputs, dependencies, and trigger conditions.
5"""
6from __future__ import annotations
8from typing import Any
10from pydantic import BaseModel, Field, ConfigDict
13# =============================================================================
14# Trigger Conditions
15# =============================================================================
17class TriggerCondition(BaseModel):
18 """Condition for when a node should be triggered.
20 The Supervisor collects matching conditions as hints for LLM decision.
21 If no LLM is available, rule-based matching is used as fallback.
22 """
23 model_config = ConfigDict(frozen=True)
25 # Priority (higher = evaluated first)
26 priority: int = Field(default=0, description="Evaluation priority. Higher values are evaluated first.")
28 # Rule-based conditions
29 when: dict[str, Any] | None = Field(
30 default=None,
31 description="Match conditions. {slice.field: expected_value, ...}",
32 )
33 when_not: dict[str, Any] | None = Field(
34 default=None,
35 description="Non-match conditions. Matches when these are NOT true.",
36 )
38 # LLM decision hint
39 llm_hint: str | None = Field(
40 default=None,
41 description="Hint for LLM when making routing decisions.",
42 )
45# =============================================================================
46# Node Contract
47# =============================================================================
49class NodeContract(BaseModel):
50 """Node I/O contract.
52 Each node defines this as a CONTRACT class variable.
53 Registry uses this for I/O validation, routing, and dependency analysis.
55 Example:
56 class LikeHandlerNode(ModularNode):
57 CONTRACT = NodeContract(
58 name="like_handler",
59 description="Records product LIKE to card",
60 reads=["request", "card", "shopping"],
61 writes=["card", "shopping", "response"],
62 ...
63 )
64 """
65 model_config = ConfigDict(frozen=True)
67 # === Identification ===
68 name: str = Field(description="Node name (key for graph registration)")
69 description: str = Field(description="Node role description")
71 # === I/O Definition (by slice) ===
72 reads: list[str] = Field(
73 description="List of slice names to read from"
74 )
75 writes: list[str] = Field(
76 description="List of slice names to write to"
77 )
79 # === Dependencies ===
80 requires_llm: bool = Field(
81 default=False,
82 description="Whether LLM client is required",
83 )
84 services: list[str] = Field(
85 default_factory=list,
86 description="Required service names (card_service, shopify_service, etc.)",
87 )
89 # === Supervisor ===
90 supervisor: str = Field(
91 description="Supervisor this node belongs to",
92 )
94 # === Trigger Conditions ===
95 trigger_conditions: list[TriggerCondition] = Field(
96 default_factory=list,
97 description="List of conditions that trigger this node",
98 )
100 # === Terminal Condition ===
101 is_terminal: bool = Field(
102 default=False,
103 description="Whether this node should transition to END after execution",
104 )
106 def get_highest_priority_condition(self) -> TriggerCondition | None:
107 """Get the highest priority trigger condition."""
108 if not self.trigger_conditions:
109 return None
110 return max(self.trigger_conditions, key=lambda c: c.priority)
112 def get_llm_hints(self) -> list[str]:
113 """Get all LLM hints."""
114 return [c.llm_hint for c in self.trigger_conditions if c.llm_hint]
117# =============================================================================
118# I/O Types
119# =============================================================================
121class NodeInputs(BaseModel):
122 """Node inputs.
124 Holds slices extracted based on Contract.reads.
125 """
126 model_config = ConfigDict(extra="allow") # Allow dynamic slice addition
128 def get_slice(self, name: str) -> dict:
129 """Get specified slice."""
130 return getattr(self, name, {})
133class NodeOutputs(BaseModel):
134 """Node outputs.
136 Holds slices to update based on Contract.writes.
137 """
138 model_config = ConfigDict(extra="allow") # Allow dynamic slice addition
140 def to_state_updates(self) -> dict[str, dict]:
141 """Convert to State update dict."""
142 return {k: v for k, v in self.model_dump().items() if v is not None}