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.
@@ -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())