graphloom 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
graphloom/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """graphloom — a minimal generic agent-loop framework on top of LangGraph.
2
+
3
+ `build_agent_graph` assembles a standard ReAct loop
4
+ (ai / tool / history / compaction / finish) with dependency-injected
5
+ llm, checkpointer, tools, and runtime_context. Everything transport- or
6
+ business-specific (HITL, subagent dispatch, artifact delivery over a wire)
7
+ is a tool the caller supplies; the framework wires only the loop.
8
+
9
+ Quick start::
10
+
11
+ from graphloom import build_agent_graph, build_initial_agent_state
12
+ graph = build_agent_graph(custom_system_prompt=..., tools=[...], llm=...)
13
+ await graph.ainvoke(build_initial_agent_state(input_query=...))
14
+ """
15
+ from graphloom.graph_builder import build_agent_graph
16
+ from graphloom.model.state import AgentState, build_initial_agent_state
17
+ from graphloom.model.subagents import SubAgentSpec, SubAgentRunContext
18
+ from graphloom.model.base_tool_input import StandardThoughtInput, PlannerThoughtInput
19
+
20
+ __all__ = [
21
+ "build_agent_graph",
22
+ "AgentState",
23
+ "build_initial_agent_state",
24
+ "SubAgentSpec",
25
+ "SubAgentRunContext",
26
+ "StandardThoughtInput",
27
+ "PlannerThoughtInput",
28
+ ]
29
+
30
+ __version__ = "0.1.0"
graphloom/config.py ADDED
@@ -0,0 +1,56 @@
1
+ """
2
+ Constants controlling the context-compaction node inserted after `history`.
3
+
4
+ All values can be overridden via environment variables (GRAPHLOOM_* prefix) to
5
+ make tuning possible without a redeploy. Keep this file free of any runtime
6
+ dependency beyond `os`.
7
+ """
8
+ import os
9
+
10
+
11
+ def _env_int(name: str, default: int) -> int:
12
+ try:
13
+ return int(os.environ.get(name, default))
14
+ except (TypeError, ValueError):
15
+ return default
16
+
17
+
18
+ def _env_float(name: str, default: float) -> float:
19
+ try:
20
+ return float(os.environ.get(name, default))
21
+ except (TypeError, ValueError):
22
+ return default
23
+
24
+
25
+ # Effective model context window in tokens. Used as the denominator for the
26
+ # trigger ratio. Should roughly match the smallest model you run.
27
+ MODEL_CONTEXT_WINDOW: int = _env_int("GRAPHLOOM_MODEL_CONTEXT_WINDOW", 830_000)
28
+
29
+ # Soft trigger: when estimated context tokens reach this fraction of the
30
+ # window, the compaction node kicks in synchronously.
31
+ COMPACT_TRIGGER_RATIO: float = _env_float("GRAPHLOOM_COMPACT_TRIGGER_RATIO", 0.75)
32
+
33
+ # Target size after compaction (fraction of the window). Used as a soft
34
+ # character budget for the summarizing LLM's action_results field.
35
+ COMPACT_TARGET_RATIO: float = _env_float("GRAPHLOOM_COMPACT_TARGET_RATIO", 0.12)
36
+
37
+ # How many trailing past_steps are kept verbatim (never compacted).
38
+ COMPACT_KEEP_RECENT_STEPS: int = _env_int("GRAPHLOOM_COMPACT_KEEP_RECENT", 5)
39
+
40
+ # Anti-thrashing: hard cap on recursive compaction attempts per node call.
41
+ COMPACT_MAX_RETRY: int = _env_int("GRAPHLOOM_COMPACT_MAX_RETRY", 2)
42
+
43
+ # Emergency truncation: when retries exhaust, trim the longest action_results
44
+ # among the recent (kept) steps down to this char budget.
45
+ COMPACT_EMERGENCY_TRUNC_CHARS: int = _env_int("GRAPHLOOM_COMPACT_EMERGENCY_TRUNC_CHARS", 500)
46
+
47
+ # tiktoken encoding to use for estimation. cl100k_base is OpenAI-compatible
48
+ # and a reasonable proxy for other providers exposed via an OpenAI API shim.
49
+ COMPACT_TOKENIZER_ENCODING: str = os.environ.get("GRAPHLOOM_COMPACT_TOKENIZER", "cl100k_base")
50
+
51
+ # Sentinel key inside past_steps[0] that signals the reducer to REPLACE the
52
+ # channel instead of appending. See `add_past_steps` in state.py.
53
+ COMPACT_SENTINEL_KEY: str = "__compact_replace__"
54
+
55
+ # Concurrency ceiling for parallel subagent dispatch.
56
+ SUBAGENT_MAX_CONCURRENCY = int(os.getenv("SUBAGENT_MAX_CONCURRENCY", "10"))
@@ -0,0 +1,149 @@
1
+ """graph_builder.py — the generic agent-loop factory.
2
+
3
+ `build_agent_graph` assembles a standard ReAct-style loop:
4
+ observer? → ai → tool → route → history → compaction → ai (+ finish)
5
+
6
+ Everything transport- or business-specific (HITL, subagent dispatch, artifact
7
+ delivery over a wire) is a tool the caller supplies; the framework wires only
8
+ the loop and the structural nodes.
9
+ """
10
+ from typing import Any, Callable, List, Optional
11
+
12
+ from langchain_core.language_models import BaseChatModel
13
+ from langgraph.graph import END, StateGraph
14
+
15
+ from graphloom.nodes.ai import create_ai_node
16
+ from graphloom.nodes.compaction import create_context_compaction_node
17
+ from graphloom.nodes.finish import create_finish_node
18
+ from graphloom.nodes.history import create_history_node
19
+ from graphloom.nodes.tool import create_tool_node
20
+ from graphloom.model.state import AgentState
21
+ from graphloom.model.subagents import SubAgentSpec
22
+ from graphloom.prompt.stack import create_prompt_stack
23
+ from graphloom.tools.artifact import (
24
+ write_artifact, read_artifact, patch_artifact, deliver_artifact,
25
+ )
26
+
27
+ _BUILTIN_ARTIFACT_TOOLS = [write_artifact, read_artifact, patch_artifact, deliver_artifact]
28
+
29
+
30
+ def build_agent_graph(
31
+ *,
32
+ custom_system_prompt: str,
33
+ tools: List[Any],
34
+ llm: BaseChatModel,
35
+ find_fault: Optional[Any] = None,
36
+ custom_find_fault: Optional[Callable] = None,
37
+ observer: Optional[Callable] = None,
38
+ subagents: Optional[List[SubAgentSpec]] = None,
39
+ checkpointer=None,
40
+ tool_filter: Optional[Callable] = None,
41
+ allow_direct_reply: bool = False,
42
+ available_skills: Optional[List[str]] = None,
43
+ skills_dir: Optional[str] = None,
44
+ ):
45
+ # Deduplicate user tools while preserving the FIRST occurrence (highest priority)
46
+ unique_tools = []
47
+ seen_names = set()
48
+ for t in tools:
49
+ t_name = getattr(t, "name", None)
50
+ if t_name not in seen_names:
51
+ unique_tools.append(t)
52
+ if t_name:
53
+ seen_names.add(t_name)
54
+
55
+ # Auto-inject builtin artifact tools (deduplicated)
56
+ builtin = [t for t in _BUILTIN_ARTIFACT_TOOLS if t.name not in seen_names]
57
+ compiled_tools = unique_tools + builtin
58
+ compiled_prompt = str(custom_system_prompt or "").strip()
59
+ if not compiled_prompt:
60
+ raise ValueError("custom_system_prompt must be provided.")
61
+ compiled_subagents = list(subagents or [])
62
+ if compiled_subagents:
63
+ # Lazy import: dispatch_subagents is an optional builtin, only needed
64
+ # when subagents are actually wired in.
65
+ from graphloom.tools.dispatch import compose_system_prompt, create_dispatch_subagents_tool
66
+ compiled_prompt = compose_system_prompt(compiled_prompt, compiled_subagents)
67
+ compiled_tools.append(create_dispatch_subagents_tool(compiled_subagents, checkpointer))
68
+
69
+ # Lazy import: find_fault is an optional review node.
70
+ find_fault_node = None
71
+ if isinstance(find_fault, str):
72
+ from graphloom.nodes.find_fault import create_find_fault_node
73
+ find_fault_node = create_find_fault_node(find_fault, llm)
74
+ elif find_fault is not None:
75
+ find_fault_node = find_fault
76
+
77
+ prompt_stack = create_prompt_stack(
78
+ custom_system_prompt=compiled_prompt,
79
+ available_skills=available_skills,
80
+ skills_dir=skills_dir,
81
+ )
82
+ ai_node = create_ai_node(prompt_stack=prompt_stack, tools=compiled_tools, llm=llm, tool_filter=tool_filter)
83
+ workflow = StateGraph(AgentState)
84
+
85
+ if observer:
86
+ workflow.add_node("observer", observer)
87
+ workflow.add_node("ai", ai_node)
88
+ workflow.add_node("tool", create_tool_node(compiled_tools, allow_direct_reply=allow_direct_reply))
89
+ if custom_find_fault:
90
+ workflow.add_node("custom_find_fault", custom_find_fault)
91
+ if find_fault_node:
92
+ workflow.add_node("find_fault", find_fault_node)
93
+ workflow.add_node("history", create_history_node())
94
+ workflow.add_node("compaction", create_context_compaction_node(prompt_stack, llm))
95
+ workflow.add_node("finish", create_finish_node())
96
+
97
+ workflow.set_entry_point("observer" if observer else "ai")
98
+ if observer:
99
+ workflow.add_edge("observer", "ai")
100
+
101
+ def route_after_tool(state: AgentState) -> str:
102
+ # Once deliver_artifact has marked the run as ended (end_tag=True), we
103
+ # always want to exit the loop — even if the manifest is empty (e.g.
104
+ # pure-text answer with no artifacts). Find-fault nodes are still
105
+ # consulted when a custom reviewer is wired in; empty manifests are
106
+ # treated as a no-op by those reviewers.
107
+ if state.get("end_tag"):
108
+ if custom_find_fault:
109
+ return "custom_find_fault"
110
+ if find_fault_node:
111
+ return "find_fault"
112
+ return "finish"
113
+ return "history"
114
+
115
+ def route_after_custom_find_fault(state: AgentState) -> str:
116
+ if list(state.get("current_delivery_manifest", []) or []):
117
+ if find_fault_node:
118
+ return "find_fault"
119
+ return "finish"
120
+ return "history"
121
+
122
+ def route_after_find_fault(state: AgentState) -> str:
123
+ if list(state.get("current_delivery_manifest", []) or []):
124
+ return "finish"
125
+ return "history"
126
+
127
+ workflow.add_edge("ai", "tool")
128
+
129
+ tool_targets = {"history": "history", "finish": "finish"}
130
+ if custom_find_fault:
131
+ tool_targets["custom_find_fault"] = "custom_find_fault"
132
+ if find_fault_node:
133
+ tool_targets["find_fault"] = "find_fault"
134
+ workflow.add_conditional_edges("tool", route_after_tool, tool_targets)
135
+
136
+ if custom_find_fault:
137
+ custom_targets = {"history": "history", "finish": "finish"}
138
+ if find_fault_node:
139
+ custom_targets["find_fault"] = "find_fault"
140
+ workflow.add_conditional_edges("custom_find_fault", route_after_custom_find_fault, custom_targets)
141
+
142
+ if find_fault_node:
143
+ workflow.add_conditional_edges("find_fault", route_after_find_fault, {"history": "history", "finish": "finish"})
144
+
145
+ workflow.add_edge("history", "compaction")
146
+ workflow.add_edge("compaction", "observer" if observer else "ai")
147
+ workflow.add_edge("finish", END)
148
+
149
+ return workflow.compile(checkpointer=checkpointer)
@@ -0,0 +1,23 @@
1
+ """Data types: state, reducers, schemas, subagent specs."""
2
+ from graphloom.model.state import AgentState, build_initial_agent_state
3
+ from graphloom.model.subagents import SubAgentSpec, SubAgentRunContext
4
+ from graphloom.model.base_tool_input import StandardThoughtInput, PlannerThoughtInput
5
+ from graphloom.model.artifact_manifest import (
6
+ ArtifactManifestEntry,
7
+ merge_artifact_manifest,
8
+ replace_artifact_manifest,
9
+ normalize_artifact_manifest_entry,
10
+ )
11
+
12
+ __all__ = [
13
+ "AgentState",
14
+ "build_initial_agent_state",
15
+ "SubAgentSpec",
16
+ "SubAgentRunContext",
17
+ "StandardThoughtInput",
18
+ "PlannerThoughtInput",
19
+ "ArtifactManifestEntry",
20
+ "merge_artifact_manifest",
21
+ "replace_artifact_manifest",
22
+ "normalize_artifact_manifest_entry",
23
+ ]
@@ -0,0 +1,131 @@
1
+ import os
2
+ from datetime import datetime
3
+ from typing import Any, Dict, List
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class ArtifactManifestEntry(BaseModel):
9
+ path: str = Field(default="", description="Absolute artifact path.")
10
+ filename: str = Field(default="", description="Basename of the artifact path.")
11
+ type: str = Field(default="unknown", description="Artifact type such as py, js, wasm, md, txt, json, or bin.")
12
+ role: str = Field(default="supporting", description="Artifact role, for example crawler_entry, signature_runtime, or readme.")
13
+ producer: str = Field(default="", description="Producer name for traceability.")
14
+ size_bytes: int = Field(default=0, description="Artifact file size.")
15
+ created_at: str = Field(default="", description="ISO timestamp from filesystem metadata.")
16
+ updated_at: str = Field(default="", description="ISO timestamp from filesystem metadata.")
17
+ is_binary: bool = Field(default=False, description="Whether the artifact is binary.")
18
+ summary: str = Field(default="")
19
+ tags: List[str] = Field(default_factory=list)
20
+
21
+
22
+ _TEXT_LIKE_EXTENSIONS = {"py", "js", "ts", "json", "md", "txt", "yaml", "yml", "html", "css", "xml", "csv", "sh"}
23
+
24
+
25
+ def _iso_from_ts(timestamp: float) -> str:
26
+ try:
27
+ return datetime.fromtimestamp(timestamp).astimezone().isoformat(timespec="seconds")
28
+ except Exception:
29
+ return ""
30
+
31
+
32
+ def _artifact_type(path: str) -> str:
33
+ ext = os.path.splitext(path)[1].lower().lstrip(".")
34
+ return ext or "unknown"
35
+
36
+
37
+ def _is_binary(path: str, artifact_type: str) -> bool:
38
+ if artifact_type in _TEXT_LIKE_EXTENSIONS:
39
+ return False
40
+ if artifact_type in {"wasm", "png", "jpg", "jpeg", "gif", "ico", "pdf", "zip", "bin"}:
41
+ return True
42
+ try:
43
+ with open(path, "rb") as handle:
44
+ sample = handle.read(2048)
45
+ except OSError:
46
+ return False
47
+ return b"\x00" in sample
48
+
49
+
50
+ def normalize_artifact_manifest_entry(entry: Dict[str, Any]) -> Dict[str, Any]:
51
+ data = dict(entry or {})
52
+ path = str(data.get("path") or "").strip()
53
+ filename = str(data.get("filename") or "").strip()
54
+
55
+ if path:
56
+ resolved_path = os.path.abspath(path)
57
+ data["path"] = resolved_path
58
+ if not filename:
59
+ data["filename"] = os.path.basename(resolved_path)
60
+ if not data.get("type"):
61
+ data["type"] = _artifact_type(resolved_path)
62
+ if os.path.exists(resolved_path):
63
+ try:
64
+ stat_result = os.stat(resolved_path)
65
+ data.setdefault("size_bytes", int(stat_result.st_size))
66
+ data.setdefault("created_at", _iso_from_ts(stat_result.st_ctime))
67
+ data.setdefault("updated_at", _iso_from_ts(stat_result.st_mtime))
68
+ except OSError:
69
+ pass
70
+ data.setdefault("is_binary", _is_binary(resolved_path, str(data.get("type") or "unknown")))
71
+
72
+ return ArtifactManifestEntry(**data).model_dump()
73
+
74
+
75
+ def merge_artifact_manifest(left: List[Dict[str, Any]], right: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
76
+ left = list(left or [])
77
+ right = list(right or [])
78
+
79
+ merged: Dict[str, Dict[str, Any]] = {}
80
+ for raw_item in left + right:
81
+ if not isinstance(raw_item, dict):
82
+ continue
83
+ item = normalize_artifact_manifest_entry(raw_item)
84
+ path_value = str(item.get("path") or "").strip()
85
+ if not path_value:
86
+ continue
87
+
88
+ previous = merged.get(path_value)
89
+ if previous is None:
90
+ merged[path_value] = item
91
+ continue
92
+
93
+ prev_updated = str(previous.get("updated_at") or "")
94
+ curr_updated = str(item.get("updated_at") or "")
95
+ prev_created = str(previous.get("created_at") or "")
96
+ curr_created = str(item.get("created_at") or "")
97
+ if (curr_updated, curr_created, path_value) >= (prev_updated, prev_created, path_value):
98
+ merged[path_value] = item
99
+
100
+ return sorted(
101
+ merged.values(),
102
+ key=lambda item: (
103
+ str(item.get("updated_at") or ""),
104
+ str(item.get("created_at") or ""),
105
+ str(item.get("path") or ""),
106
+ ),
107
+ reverse=True,
108
+ )
109
+
110
+
111
+ def replace_artifact_manifest(left: List[Dict[str, Any]], right: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
112
+ right = list(right or [])
113
+
114
+ replaced: List[Dict[str, Any]] = []
115
+ for raw_item in right:
116
+ if not isinstance(raw_item, dict):
117
+ continue
118
+ path_value = str(raw_item.get("path") or "").strip()
119
+ if not path_value:
120
+ continue
121
+ replaced.append(normalize_artifact_manifest_entry(raw_item))
122
+
123
+ return sorted(
124
+ replaced,
125
+ key=lambda item: (
126
+ str(item.get("updated_at") or ""),
127
+ str(item.get("created_at") or ""),
128
+ str(item.get("path") or ""),
129
+ ),
130
+ reverse=True,
131
+ )
@@ -0,0 +1,53 @@
1
+ from typing import List, Optional
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class PlannerSummary(BaseModel):
7
+ """High-level planner reasoning."""
8
+ task_analysis: str = Field(
9
+ description="What the user wants, task type, and likely difficulty.",
10
+ )
11
+ progress_review: str = Field(
12
+ description="What has already been accomplished and what is still missing.",
13
+ )
14
+ strategy: str = Field(
15
+ description="What to do in the current phase only.",
16
+ )
17
+ concise_assessment: str = Field(
18
+ description="Short user-facing summary of why this plan makes sense now.",
19
+ )
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Base thought inputs
24
+ # ---------------------------------------------------------------------------
25
+
26
+ class StandardThoughtInput(BaseModel):
27
+ """Unified chain-of-thought parameters for all agent tools."""
28
+ model_config = {"extra": "allow"}
29
+
30
+ last_step_review: str = Field(
31
+ description="Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain."
32
+ )
33
+ working_notes: str = Field(
34
+ description="1-3 sentences of specific notes on this step and overall progress. Put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc."
35
+ )
36
+ next_action: str = Field(
37
+ description="State the next immediate goal and action to achieve it, in one clear sentence."
38
+ )
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Planner-level thought input (for main orchestrator tools)
42
+ # ---------------------------------------------------------------------------
43
+
44
+ class PlannerThoughtInput(StandardThoughtInput):
45
+ """Extends standard chain-of-thought with planner-specific structured fields.
46
+
47
+ Used by dispatch_subagents and request_user_interaction in the main orchestrator.
48
+ Forces the LLM to produce structured planning outputs with every tool call.
49
+ """
50
+ planner_summary: PlannerSummary = Field(
51
+ description="High-level planner reasoning for the user. "
52
+ "Covers only the current phase.",
53
+ )
@@ -0,0 +1,110 @@
1
+ from typing import Annotated, Any, Dict, List, Optional, Sequence, TypedDict
2
+
3
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
4
+ from langgraph.graph.message import add_messages
5
+
6
+ from graphloom.config import COMPACT_SENTINEL_KEY
7
+ from graphloom.model.artifact_manifest import merge_artifact_manifest, replace_artifact_manifest
8
+
9
+
10
+ def add_past_steps(left: List[Dict[str, Any]], right: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
11
+ if left is None:
12
+ left = []
13
+ if right is None:
14
+ right = []
15
+ # Sentinel-aware replace: when the compaction node wants to swap the whole
16
+ # channel, it returns [{"__compact_replace__": True}, compacted, *recent].
17
+ # All other callers keep append semantics.
18
+ if right and isinstance(right[0], dict) and right[0].get(COMPACT_SENTINEL_KEY) is True:
19
+ return list(right[1:])
20
+
21
+ combined = list(left)
22
+ step_index = {
23
+ str(step.get("step_id")): idx
24
+ for idx, step in enumerate(combined)
25
+ if isinstance(step, dict) and step.get("step_id")
26
+ }
27
+ for step in right:
28
+ if isinstance(step, dict) and step.get("step_id"):
29
+ step_id = str(step.get("step_id"))
30
+ if step_id in step_index:
31
+ combined[step_index[step_id]] = step
32
+ continue
33
+ step_index[step_id] = len(combined)
34
+ combined.append(step)
35
+ return combined
36
+
37
+
38
+ def keep_max_step_counter(left: Any, right: Any) -> int:
39
+ """Reducer for `step_counter`. Always keep the highest value seen so the
40
+ counter is strictly monotonic across the graph — including across
41
+ compaction runs that shrink `past_steps`. Using a dedicated counter
42
+ (instead of `len(past_steps)`) is what prevents step_id collisions when
43
+ compaction folds earlier steps into a summary."""
44
+ try:
45
+ left_int = int(left or 0)
46
+ except (TypeError, ValueError):
47
+ left_int = 0
48
+ try:
49
+ right_int = int(right or 0)
50
+ except (TypeError, ValueError):
51
+ right_int = 0
52
+ return max(left_int, right_int)
53
+
54
+
55
+ _TOOL_HISTORY_MAX = 8
56
+
57
+
58
+ def add_tool_results(left: List[Dict[str, Any]], right: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
59
+ if left is None:
60
+ left = []
61
+ if right is None:
62
+ right = []
63
+ combined = left + right
64
+ return combined[-_TOOL_HISTORY_MAX:]
65
+
66
+
67
+ class AgentState(TypedDict):
68
+ current_agent_name: str
69
+ messages: Annotated[Sequence[BaseMessage], add_messages]
70
+ input_query: str
71
+ attach_message_parts: Optional[List[Dict[str, Any]]]
72
+ session_id: Optional[str]
73
+ latest_ai_message: Optional[AIMessage]
74
+
75
+ past_steps: Annotated[List[Dict[str, Any]], add_past_steps]
76
+ step_counter: Annotated[int, keep_max_step_counter]
77
+ tool_result_history: List[Dict[str, Any]]
78
+ observer_message_parts: Optional[List[HumanMessage]]
79
+
80
+ input_artifact_manifest: Annotated[List[Dict[str, Any]], replace_artifact_manifest]
81
+ current_delivery_manifest: Annotated[List[Dict[str, Any]], replace_artifact_manifest]
82
+ approved_artifact_manifest: Annotated[List[Dict[str, Any]], merge_artifact_manifest]
83
+
84
+ final_reply: Optional[str]
85
+ agent_status: Optional[str]
86
+
87
+ end_tag: bool
88
+
89
+
90
+ def build_initial_agent_state(**overrides: Any) -> AgentState:
91
+ state: AgentState = {
92
+ "current_agent_name": "main",
93
+ "messages": [],
94
+ "input_query": "",
95
+ "attach_message_parts": None,
96
+ "session_id": None,
97
+ "latest_ai_message": None,
98
+ "past_steps": [],
99
+ "step_counter": 0,
100
+ "tool_result_history": [],
101
+ "observer_message_parts": None,
102
+ "input_artifact_manifest": [],
103
+ "current_delivery_manifest": [],
104
+ "approved_artifact_manifest": [],
105
+ "final_reply": "",
106
+ "agent_status": "running",
107
+ "end_tag": False,
108
+ }
109
+ state.update(overrides)
110
+ return state
@@ -0,0 +1,20 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Callable, Optional
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class SubAgentRunContext:
7
+ parent_session_id: str
8
+ child_session_id: str
9
+ step: Any
10
+ result: Any = None
11
+ error: Optional[BaseException] = None
12
+ user_id: str = ""
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SubAgentSpec:
17
+ agent_name: str
18
+ description: str
19
+ factory: Callable[[], Any]
20
+ cleanup_hook: Optional[Callable[[SubAgentRunContext], Any]] = None
@@ -0,0 +1 @@
1
+ """Structural graph nodes (internal)."""