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 +30 -0
- graphloom/config.py +56 -0
- graphloom/graph_builder.py +149 -0
- graphloom/model/__init__.py +23 -0
- graphloom/model/artifact_manifest.py +131 -0
- graphloom/model/base_tool_input.py +53 -0
- graphloom/model/state.py +110 -0
- graphloom/model/subagents.py +20 -0
- graphloom/nodes/__init__.py +1 -0
- graphloom/nodes/ai.py +168 -0
- graphloom/nodes/compaction.py +296 -0
- graphloom/nodes/find_fault.py +257 -0
- graphloom/nodes/finish.py +18 -0
- graphloom/nodes/history.py +87 -0
- graphloom/nodes/interrupt_guard.py +27 -0
- graphloom/nodes/tool.py +259 -0
- graphloom/prompt/__init__.py +1 -0
- graphloom/prompt/context_renderer.py +126 -0
- graphloom/prompt/find_fault_system_prompt.py +29 -0
- graphloom/prompt/message_builder.py +50 -0
- graphloom/prompt/stack.py +47 -0
- graphloom/prompt/system_prompt.py +129 -0
- graphloom/skills/__init__.py +4 -0
- graphloom/skills/loader.py +128 -0
- graphloom/tools/__init__.py +1 -0
- graphloom/tools/artifact.py +414 -0
- graphloom/tools/dispatch.py +374 -0
- graphloom/util/__init__.py +1 -0
- graphloom/util/message_utils.py +13 -0
- graphloom/util/session_store.py +47 -0
- graphloom/util/token_counter.py +73 -0
- graphloom-0.1.0.dist-info/METADATA +301 -0
- graphloom-0.1.0.dist-info/RECORD +36 -0
- graphloom-0.1.0.dist-info/WHEEL +5 -0
- graphloom-0.1.0.dist-info/licenses/LICENSE +21 -0
- graphloom-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|
graphloom/model/state.py
ADDED
|
@@ -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)."""
|