aofire-python-agent 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.
- aofire_python_agent-0.1.0.dist-info/METADATA +405 -0
- aofire_python_agent-0.1.0.dist-info/RECORD +23 -0
- aofire_python_agent-0.1.0.dist-info/WHEEL +5 -0
- aofire_python_agent-0.1.0.dist-info/entry_points.txt +7 -0
- aofire_python_agent-0.1.0.dist-info/licenses/LICENSE +28 -0
- aofire_python_agent-0.1.0.dist-info/top_level.txt +1 -0
- python_agent/CLAUDE.md +105 -0
- python_agent/__init__.py +3 -0
- python_agent/agent_utils.py +61 -0
- python_agent/call_graph.py +694 -0
- python_agent/coding_agent.py +193 -0
- python_agent/convergence_agent.py +362 -0
- python_agent/dag_integrity.py +198 -0
- python_agent/dag_utils.py +181 -0
- python_agent/discovery_agent.py +348 -0
- python_agent/divergence_agent.py +302 -0
- python_agent/ontology.py +270 -0
- python_agent/planning_agent.py +83 -0
- python_agent/py.typed +0 -0
- python_agent/rules.py +383 -0
- python_agent/tool_guard.py +164 -0
- python_agent/tools/__init__.py +0 -0
- python_agent/types.py +38 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Coding agent: writes Python code to production standards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from claude_agent_sdk import (
|
|
12
|
+
AssistantMessage,
|
|
13
|
+
ClaudeAgentOptions,
|
|
14
|
+
ResultMessage,
|
|
15
|
+
query,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from python_agent.agent_utils import print_text_blocks
|
|
19
|
+
from python_agent.dag_utils import load_dag
|
|
20
|
+
from python_agent.rules import coding_system_prompt
|
|
21
|
+
from python_agent.tool_guard import make_tool_guard
|
|
22
|
+
|
|
23
|
+
ESCALATION_MODEL = "claude-opus-4-6"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _prompt_stream(
|
|
27
|
+
text: str,
|
|
28
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
29
|
+
"""Wrap a string prompt as an async iterable.
|
|
30
|
+
|
|
31
|
+
Required by claude_agent_sdk when can_use_tool is set.
|
|
32
|
+
"""
|
|
33
|
+
yield {"role": "user", "content": text}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def run_query(
|
|
37
|
+
task: str, options: Any,
|
|
38
|
+
) -> Any:
|
|
39
|
+
"""Run a single query and return the ResultMessage."""
|
|
40
|
+
result: Any = None
|
|
41
|
+
async for message in query(
|
|
42
|
+
prompt=_prompt_stream(task), options=options,
|
|
43
|
+
):
|
|
44
|
+
if isinstance(message, AssistantMessage):
|
|
45
|
+
print_text_blocks(message)
|
|
46
|
+
elif isinstance(message, ResultMessage):
|
|
47
|
+
cost = message.total_cost_usd or 0.0
|
|
48
|
+
print(f"\nDone. Cost: ${cost:.4f}")
|
|
49
|
+
result = message
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def should_escalate(
|
|
54
|
+
result: Any, max_turns: int | None,
|
|
55
|
+
) -> bool:
|
|
56
|
+
"""Decide whether to escalate to a stronger model."""
|
|
57
|
+
if result is None:
|
|
58
|
+
return False
|
|
59
|
+
if result.is_error:
|
|
60
|
+
return True
|
|
61
|
+
if max_turns is not None and result.num_turns >= max_turns:
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def remaining_budget(
|
|
67
|
+
result: Any, max_budget: float | None,
|
|
68
|
+
) -> float | None:
|
|
69
|
+
"""Calculate remaining budget after a query."""
|
|
70
|
+
if max_budget is None:
|
|
71
|
+
return None
|
|
72
|
+
if result is None or result.total_cost_usd is None:
|
|
73
|
+
return max_budget
|
|
74
|
+
return float(max_budget - result.total_cost_usd)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _load_ontology_json(
|
|
78
|
+
dag_file: str | None,
|
|
79
|
+
) -> str | None:
|
|
80
|
+
"""Load ontology JSON from a DAG file, if provided."""
|
|
81
|
+
if dag_file is None:
|
|
82
|
+
return None
|
|
83
|
+
dag = load_dag(dag_file, project_name="")
|
|
84
|
+
node = dag.get_current_node()
|
|
85
|
+
if node is None:
|
|
86
|
+
return None
|
|
87
|
+
return node.ontology.model_dump_json(indent=2)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def run(
|
|
91
|
+
task: str, project_dir: str, model: str,
|
|
92
|
+
max_turns: int | None, max_budget: float | None,
|
|
93
|
+
dag_file: str | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Run the coding agent on a task, escalating to Opus if stuck."""
|
|
96
|
+
ontology_json = _load_ontology_json(dag_file)
|
|
97
|
+
prompt = coding_system_prompt(
|
|
98
|
+
project_dir, ontology_json=ontology_json,
|
|
99
|
+
)
|
|
100
|
+
guard = make_tool_guard(project_dir)
|
|
101
|
+
options = ClaudeAgentOptions(
|
|
102
|
+
model=model,
|
|
103
|
+
system_prompt=prompt,
|
|
104
|
+
allowed_tools=["Read", "Edit", "Bash", "Glob", "Grep"],
|
|
105
|
+
permission_mode="bypassPermissions",
|
|
106
|
+
max_turns=max_turns,
|
|
107
|
+
max_budget_usd=max_budget,
|
|
108
|
+
cwd=project_dir,
|
|
109
|
+
can_use_tool=guard,
|
|
110
|
+
)
|
|
111
|
+
result = await run_query(task, options)
|
|
112
|
+
if model == ESCALATION_MODEL:
|
|
113
|
+
return
|
|
114
|
+
if not should_escalate(result, max_turns):
|
|
115
|
+
return
|
|
116
|
+
print("\nEscalating to Opus...")
|
|
117
|
+
budget = remaining_budget(result, max_budget)
|
|
118
|
+
escalation_task = (
|
|
119
|
+
"Continue this task. Partial changes may already "
|
|
120
|
+
f"exist in the working directory.\n\n{task}"
|
|
121
|
+
)
|
|
122
|
+
escalation_options = ClaudeAgentOptions(
|
|
123
|
+
model=ESCALATION_MODEL,
|
|
124
|
+
system_prompt=prompt,
|
|
125
|
+
allowed_tools=["Read", "Edit", "Bash", "Glob", "Grep"],
|
|
126
|
+
permission_mode="bypassPermissions",
|
|
127
|
+
max_turns=max_turns,
|
|
128
|
+
max_budget_usd=budget,
|
|
129
|
+
cwd=project_dir,
|
|
130
|
+
can_use_tool=guard,
|
|
131
|
+
)
|
|
132
|
+
await run_query(escalation_task, escalation_options)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_args(
|
|
136
|
+
argv: list[str] | None = None,
|
|
137
|
+
) -> argparse.Namespace:
|
|
138
|
+
"""Parse command-line arguments."""
|
|
139
|
+
parser = argparse.ArgumentParser(
|
|
140
|
+
description="Run the Python coding agent on a task",
|
|
141
|
+
)
|
|
142
|
+
parser.add_argument(
|
|
143
|
+
"task",
|
|
144
|
+
help="Task description for the agent",
|
|
145
|
+
)
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
"-d", "--project-dir",
|
|
148
|
+
default=".",
|
|
149
|
+
help="Project directory to work in (default: current dir)",
|
|
150
|
+
)
|
|
151
|
+
parser.add_argument(
|
|
152
|
+
"-m", "--model",
|
|
153
|
+
default="claude-sonnet-4-6",
|
|
154
|
+
help="Model to use (default: claude-sonnet-4-6)",
|
|
155
|
+
)
|
|
156
|
+
parser.add_argument(
|
|
157
|
+
"--max-turns",
|
|
158
|
+
type=int,
|
|
159
|
+
default=30,
|
|
160
|
+
help="Maximum agent turns (default: 30)",
|
|
161
|
+
)
|
|
162
|
+
parser.add_argument(
|
|
163
|
+
"--max-budget",
|
|
164
|
+
type=float,
|
|
165
|
+
default=5.0,
|
|
166
|
+
help="Maximum budget in USD (default: 5.0)",
|
|
167
|
+
)
|
|
168
|
+
parser.add_argument(
|
|
169
|
+
"--dag-file",
|
|
170
|
+
default=None,
|
|
171
|
+
help="Path to ontology DAG JSON file for design context",
|
|
172
|
+
)
|
|
173
|
+
return parser.parse_args(argv)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main(argv: list[str] | None = None) -> int:
|
|
177
|
+
"""Entry point for the aofire-coding-agent CLI."""
|
|
178
|
+
args = parse_args(argv)
|
|
179
|
+
asyncio.run(
|
|
180
|
+
run(
|
|
181
|
+
args.task,
|
|
182
|
+
args.project_dir,
|
|
183
|
+
args.model,
|
|
184
|
+
args.max_turns,
|
|
185
|
+
args.max_budget,
|
|
186
|
+
dag_file=args.dag_file,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
sys.exit(main())
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Convergence agent: interactive candidate selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from claude_agent_sdk import (
|
|
14
|
+
ClaudeAgentOptions,
|
|
15
|
+
ClaudeSDKClient,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from python_agent.dag_utils import (
|
|
19
|
+
load_dag,
|
|
20
|
+
save_dag,
|
|
21
|
+
save_snapshot,
|
|
22
|
+
)
|
|
23
|
+
from python_agent.agent_utils import read_user_input
|
|
24
|
+
from python_agent.discovery_agent import (
|
|
25
|
+
backtrack,
|
|
26
|
+
format_ontology_summary,
|
|
27
|
+
print_response,
|
|
28
|
+
process_response,
|
|
29
|
+
)
|
|
30
|
+
from python_agent.ontology import (
|
|
31
|
+
DAGNode,
|
|
32
|
+
Decision,
|
|
33
|
+
Ontology,
|
|
34
|
+
OntologyDAG,
|
|
35
|
+
)
|
|
36
|
+
from python_agent.rules import convergence_system_prompt
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AgentState(BaseModel):
|
|
40
|
+
"""Mutable state for the convergence agent loop."""
|
|
41
|
+
|
|
42
|
+
ontology: Ontology
|
|
43
|
+
accepted: bool = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_children_summaries(
|
|
47
|
+
dag: OntologyDAG, node_id: str,
|
|
48
|
+
) -> list[tuple[int, DAGNode, str]]:
|
|
49
|
+
"""Build list of (index, node, summary) for children."""
|
|
50
|
+
children = dag.children_of(node_id)
|
|
51
|
+
result: list[tuple[int, DAGNode, str]] = []
|
|
52
|
+
for i, child in enumerate(children, 1):
|
|
53
|
+
summary = format_ontology_summary(child.ontology)
|
|
54
|
+
result.append((i, child, summary))
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_children_list(
|
|
59
|
+
children_summaries: list[tuple[int, DAGNode, str]],
|
|
60
|
+
) -> str:
|
|
61
|
+
"""Format children summaries for display."""
|
|
62
|
+
if not children_summaries:
|
|
63
|
+
return "No candidates at this node."
|
|
64
|
+
lines: list[str] = []
|
|
65
|
+
for idx, child, summary in children_summaries:
|
|
66
|
+
label = child.label or child.id
|
|
67
|
+
first_line = summary.split("\n")[0]
|
|
68
|
+
lines.append(f" {idx}. {label}: {first_line}")
|
|
69
|
+
return "\n".join(lines)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def navigate_to_node(
|
|
73
|
+
dag: OntologyDAG, node_id: str,
|
|
74
|
+
) -> Ontology | None:
|
|
75
|
+
"""Set current_node_id and return the node's ontology."""
|
|
76
|
+
node = dag.get_node(node_id)
|
|
77
|
+
if node is None:
|
|
78
|
+
return None
|
|
79
|
+
dag.current_node_id = node_id
|
|
80
|
+
return node.ontology.model_copy(deep=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_command(text: str) -> bool:
|
|
84
|
+
"""Check if text is a convergence meta-command."""
|
|
85
|
+
cmd = text.strip().lower()
|
|
86
|
+
return (
|
|
87
|
+
cmd in ("list", "show", "accept", "back")
|
|
88
|
+
or cmd.startswith("select ")
|
|
89
|
+
or cmd.startswith("save")
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_CmdResult = tuple[str, Ontology | None, bool]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _handle_list_cmd(
|
|
97
|
+
command: str, ontology: Ontology,
|
|
98
|
+
dag: OntologyDAG, dag_path: str,
|
|
99
|
+
) -> _CmdResult:
|
|
100
|
+
"""Handle 'list' command."""
|
|
101
|
+
children = get_children_summaries(
|
|
102
|
+
dag, dag.current_node_id,
|
|
103
|
+
)
|
|
104
|
+
return (format_children_list(children), None, False)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _handle_show_cmd(
|
|
108
|
+
command: str, ontology: Ontology,
|
|
109
|
+
dag: OntologyDAG, dag_path: str,
|
|
110
|
+
) -> _CmdResult:
|
|
111
|
+
"""Handle 'show' command."""
|
|
112
|
+
return (format_ontology_summary(ontology), None, False)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _handle_accept_cmd(
|
|
116
|
+
command: str, ontology: Ontology,
|
|
117
|
+
dag: OntologyDAG, dag_path: str,
|
|
118
|
+
) -> _CmdResult:
|
|
119
|
+
"""Handle 'accept' command."""
|
|
120
|
+
node = dag.get_current_node()
|
|
121
|
+
label = node.label if node else "unknown"
|
|
122
|
+
decision = Decision(
|
|
123
|
+
question="candidate-selection",
|
|
124
|
+
options=[],
|
|
125
|
+
chosen=label,
|
|
126
|
+
rationale="user-accepted",
|
|
127
|
+
)
|
|
128
|
+
save_snapshot(
|
|
129
|
+
dag, ontology,
|
|
130
|
+
f"accepted-{label}", decision,
|
|
131
|
+
)
|
|
132
|
+
save_dag(dag, dag_path)
|
|
133
|
+
return (f"Accepted: {label}. You can now refine.",
|
|
134
|
+
None, True)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _handle_back_cmd(
|
|
138
|
+
command: str, ontology: Ontology,
|
|
139
|
+
dag: OntologyDAG, dag_path: str,
|
|
140
|
+
) -> _CmdResult:
|
|
141
|
+
"""Handle 'back' command."""
|
|
142
|
+
node = backtrack(dag)
|
|
143
|
+
if node is None:
|
|
144
|
+
return ("Already at root.", None, False)
|
|
145
|
+
new_onto = node.ontology.model_copy(deep=True)
|
|
146
|
+
save_dag(dag, dag_path)
|
|
147
|
+
label = node.label or node.id
|
|
148
|
+
msg = f"Backtracked to: {node.id} ({label})"
|
|
149
|
+
return (msg, new_onto, False)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _handle_select_cmd(
|
|
153
|
+
command: str, ontology: Ontology,
|
|
154
|
+
dag: OntologyDAG, dag_path: str,
|
|
155
|
+
) -> _CmdResult:
|
|
156
|
+
"""Handle 'select <n>' command."""
|
|
157
|
+
parts = command.strip().split()
|
|
158
|
+
if len(parts) < 2:
|
|
159
|
+
return ("Usage: select <number>", None, False)
|
|
160
|
+
try:
|
|
161
|
+
index = int(parts[1])
|
|
162
|
+
except ValueError:
|
|
163
|
+
return ("Invalid number.", None, False)
|
|
164
|
+
children = dag.children_of(dag.current_node_id)
|
|
165
|
+
if index < 1 or index > len(children):
|
|
166
|
+
msg = f"Range: 1-{len(children)}"
|
|
167
|
+
return (msg, None, False)
|
|
168
|
+
child = children[index - 1]
|
|
169
|
+
new_onto = navigate_to_node(dag, child.id)
|
|
170
|
+
label = child.label or child.id
|
|
171
|
+
return (f"Selected: {label}", new_onto, False)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _handle_save_cmd(
|
|
175
|
+
command: str, ontology: Ontology,
|
|
176
|
+
dag: OntologyDAG, dag_path: str,
|
|
177
|
+
) -> _CmdResult:
|
|
178
|
+
"""Handle 'save [label]' command."""
|
|
179
|
+
label = command.strip()[4:].strip() or "snapshot"
|
|
180
|
+
save_snapshot(dag, ontology, label)
|
|
181
|
+
save_dag(dag, dag_path)
|
|
182
|
+
return (
|
|
183
|
+
f"Saved snapshot: {dag.current_node_id}",
|
|
184
|
+
None, False,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
_CmdHandler = Any # Callable signature for command handlers
|
|
189
|
+
|
|
190
|
+
_DISPATCH: dict[str, _CmdHandler] = {
|
|
191
|
+
"list": _handle_list_cmd,
|
|
192
|
+
"show": _handle_show_cmd,
|
|
193
|
+
"accept": _handle_accept_cmd,
|
|
194
|
+
"back": _handle_back_cmd,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def handle_command(
|
|
199
|
+
command: str, ontology: Ontology,
|
|
200
|
+
dag: OntologyDAG, dag_path: str,
|
|
201
|
+
) -> _CmdResult:
|
|
202
|
+
"""Dispatch a convergence meta-command.
|
|
203
|
+
|
|
204
|
+
Returns (message, new_ontology_or_None, is_accept).
|
|
205
|
+
"""
|
|
206
|
+
cmd = command.strip().lower()
|
|
207
|
+
handler = _DISPATCH.get(cmd)
|
|
208
|
+
if handler is not None:
|
|
209
|
+
result: _CmdResult = handler(
|
|
210
|
+
command, ontology, dag, dag_path,
|
|
211
|
+
)
|
|
212
|
+
return result
|
|
213
|
+
if cmd.startswith("select "):
|
|
214
|
+
return _handle_select_cmd(
|
|
215
|
+
command, ontology, dag, dag_path,
|
|
216
|
+
)
|
|
217
|
+
return _handle_save_cmd(
|
|
218
|
+
command, ontology, dag, dag_path,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def dispatch_command(
|
|
223
|
+
command: str, state: AgentState,
|
|
224
|
+
dag: OntologyDAG, dag_path: str,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Dispatch command and update agent state."""
|
|
227
|
+
msg, new_onto, is_accept = handle_command(
|
|
228
|
+
command, state.ontology, dag, dag_path,
|
|
229
|
+
)
|
|
230
|
+
print(msg)
|
|
231
|
+
if new_onto is not None:
|
|
232
|
+
state.ontology = new_onto
|
|
233
|
+
if is_accept:
|
|
234
|
+
state.accepted = True
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def maybe_process(
|
|
238
|
+
text: str, state: AgentState,
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Apply ontology updates if accepted."""
|
|
241
|
+
if state.accepted:
|
|
242
|
+
process_response(text, state.ontology)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def build_query(
|
|
246
|
+
user_input: str, state: AgentState,
|
|
247
|
+
dag: OntologyDAG,
|
|
248
|
+
) -> str:
|
|
249
|
+
"""Build LLM query with current context."""
|
|
250
|
+
from python_agent.rules import frame_data
|
|
251
|
+
children = get_children_summaries(
|
|
252
|
+
dag, dag.current_node_id,
|
|
253
|
+
)
|
|
254
|
+
ctx = format_children_list(children)
|
|
255
|
+
node = dag.get_current_node()
|
|
256
|
+
label = node.label if node else "unknown"
|
|
257
|
+
context = f"node={label}, children:\n{ctx}"
|
|
258
|
+
framed = frame_data("context-data", context)
|
|
259
|
+
return f"[Context: {framed}]\n\n{user_input}"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _init_state(
|
|
263
|
+
dag: OntologyDAG,
|
|
264
|
+
) -> AgentState | None:
|
|
265
|
+
"""Initialize agent state from DAG."""
|
|
266
|
+
node = dag.get_current_node()
|
|
267
|
+
if node is None:
|
|
268
|
+
return None
|
|
269
|
+
ontology = node.ontology.model_copy(deep=True)
|
|
270
|
+
return AgentState(ontology=ontology)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _print_status(
|
|
274
|
+
state: AgentState, dag: OntologyDAG,
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Print initial status."""
|
|
277
|
+
print(format_ontology_summary(state.ontology))
|
|
278
|
+
children = get_children_summaries(
|
|
279
|
+
dag, dag.current_node_id,
|
|
280
|
+
)
|
|
281
|
+
print(format_children_list(children))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# taint: ignore[CWE-200] -- Interactive agent displays LLM output to user
|
|
285
|
+
async def _main_loop(
|
|
286
|
+
client: Any, state: AgentState,
|
|
287
|
+
dag: OntologyDAG, dag_path: str,
|
|
288
|
+
) -> None:
|
|
289
|
+
"""Run the interactive convergence loop."""
|
|
290
|
+
while True:
|
|
291
|
+
user_input = read_user_input()
|
|
292
|
+
if user_input is None:
|
|
293
|
+
break
|
|
294
|
+
if is_command(user_input):
|
|
295
|
+
dispatch_command(
|
|
296
|
+
user_input, state, dag, dag_path,
|
|
297
|
+
)
|
|
298
|
+
continue
|
|
299
|
+
q = build_query(user_input, state, dag)
|
|
300
|
+
await client.query(q)
|
|
301
|
+
text = await print_response(client)
|
|
302
|
+
maybe_process(text, state)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
async def run(dag_path: str, model: str) -> None:
|
|
306
|
+
"""Run the convergence agent interactively."""
|
|
307
|
+
dag = load_dag(dag_path, "unknown")
|
|
308
|
+
state = _init_state(dag)
|
|
309
|
+
if state is None:
|
|
310
|
+
print("Error: DAG has no current node.")
|
|
311
|
+
return
|
|
312
|
+
_print_status(state, dag)
|
|
313
|
+
|
|
314
|
+
ontology_json = json.dumps(
|
|
315
|
+
state.ontology.model_dump(), indent=2,
|
|
316
|
+
)
|
|
317
|
+
children = get_children_summaries(
|
|
318
|
+
dag, dag.current_node_id,
|
|
319
|
+
)
|
|
320
|
+
prompt = convergence_system_prompt(
|
|
321
|
+
ontology_json, format_children_list(children),
|
|
322
|
+
)
|
|
323
|
+
options = ClaudeAgentOptions(
|
|
324
|
+
model=model,
|
|
325
|
+
system_prompt=prompt,
|
|
326
|
+
allowed_tools=[],
|
|
327
|
+
permission_mode="default",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
async with ClaudeSDKClient(options=options) as client:
|
|
331
|
+
await _main_loop(client, state, dag, dag_path)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def parse_args(
|
|
335
|
+
argv: list[str] | None = None,
|
|
336
|
+
) -> argparse.Namespace:
|
|
337
|
+
"""Parse command-line arguments."""
|
|
338
|
+
parser = argparse.ArgumentParser(
|
|
339
|
+
description="Interactive candidate convergence",
|
|
340
|
+
)
|
|
341
|
+
parser.add_argument(
|
|
342
|
+
"--dag-file",
|
|
343
|
+
required=True,
|
|
344
|
+
help="Path to DAG JSON file",
|
|
345
|
+
)
|
|
346
|
+
parser.add_argument(
|
|
347
|
+
"-m", "--model",
|
|
348
|
+
default="claude-opus-4-6",
|
|
349
|
+
help="Model to use (default: claude-opus-4-6)",
|
|
350
|
+
)
|
|
351
|
+
return parser.parse_args(argv)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def main(argv: list[str] | None = None) -> int:
|
|
355
|
+
"""Entry point for the aofire-convergence-agent CLI."""
|
|
356
|
+
args = parse_args(argv)
|
|
357
|
+
asyncio.run(run(args.dag_file, args.model))
|
|
358
|
+
return 0
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
if __name__ == "__main__":
|
|
362
|
+
sys.exit(main())
|