forgex-cli 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.
Files changed (115) hide show
  1. forgex/__init__.py +3 -0
  2. forgex/__main__.py +6 -0
  3. forgex/agents/__init__.py +36 -0
  4. forgex/agents/architect.py +233 -0
  5. forgex/agents/base.py +385 -0
  6. forgex/agents/builder.py +269 -0
  7. forgex/agents/ensemble.py +334 -0
  8. forgex/agents/expert_coder.py +283 -0
  9. forgex/agents/supervisor.py +303 -0
  10. forgex/cli/__init__.py +1 -0
  11. forgex/cli/app.py +232 -0
  12. forgex/cli/build_views.py +94 -0
  13. forgex/cli/commands/__init__.py +1 -0
  14. forgex/cli/commands/build.py +63 -0
  15. forgex/cli/commands/checkpoint.py +41 -0
  16. forgex/cli/commands/config.py +225 -0
  17. forgex/cli/commands/context.py +94 -0
  18. forgex/cli/commands/diff.py +32 -0
  19. forgex/cli/commands/do_cmd.py +50 -0
  20. forgex/cli/commands/doctor.py +175 -0
  21. forgex/cli/commands/fix.py +46 -0
  22. forgex/cli/commands/init.py +41 -0
  23. forgex/cli/commands/logs.py +102 -0
  24. forgex/cli/commands/models.py +144 -0
  25. forgex/cli/commands/plan.py +103 -0
  26. forgex/cli/commands/review.py +76 -0
  27. forgex/cli/commands/rollback.py +65 -0
  28. forgex/cli/commands/run.py +75 -0
  29. forgex/cli/commands/status.py +141 -0
  30. forgex/cli/commands/supervise.py +61 -0
  31. forgex/cli/commands/task.py +136 -0
  32. forgex/cli/commands/tools.py +116 -0
  33. forgex/cli/commands/verify.py +65 -0
  34. forgex/cli/context_memory.py +62 -0
  35. forgex/cli/intent.py +209 -0
  36. forgex/cli/progress.py +101 -0
  37. forgex/cli/repl.py +377 -0
  38. forgex/cli/runtime.py +233 -0
  39. forgex/cli/session.py +95 -0
  40. forgex/cli/ui.py +113 -0
  41. forgex/config/__init__.py +53 -0
  42. forgex/config/loader.py +189 -0
  43. forgex/config/schema.py +145 -0
  44. forgex/context/__init__.py +6 -0
  45. forgex/context/chunker.py +102 -0
  46. forgex/context/dependency_graph.py +110 -0
  47. forgex/context/indexer.py +161 -0
  48. forgex/context/manager.py +174 -0
  49. forgex/context/retriever.py +99 -0
  50. forgex/models/__init__.py +25 -0
  51. forgex/models/base.py +112 -0
  52. forgex/models/capabilities.py +21 -0
  53. forgex/models/factory.py +66 -0
  54. forgex/models/groq.py +13 -0
  55. forgex/models/local.py +10 -0
  56. forgex/models/openai_compatible.py +233 -0
  57. forgex/models/registry.py +81 -0
  58. forgex/models/router.py +332 -0
  59. forgex/orchestration/__init__.py +36 -0
  60. forgex/orchestration/checkpoints.py +117 -0
  61. forgex/orchestration/engine.py +573 -0
  62. forgex/orchestration/events.py +101 -0
  63. forgex/orchestration/lockfile.py +82 -0
  64. forgex/orchestration/retry.py +65 -0
  65. forgex/orchestration/state.py +89 -0
  66. forgex/orchestration/stores.py +90 -0
  67. forgex/orchestration/workflow.py +103 -0
  68. forgex/project.py +209 -0
  69. forgex/prompts/__init__.py +27 -0
  70. forgex/prompts/architect.md +18 -0
  71. forgex/prompts/builder.md +20 -0
  72. forgex/prompts/expert_coder.md +25 -0
  73. forgex/prompts/reviewer.md +11 -0
  74. forgex/prompts/supervisor.md +26 -0
  75. forgex/research/__init__.py +15 -0
  76. forgex/research/citations.py +16 -0
  77. forgex/research/engine.py +83 -0
  78. forgex/research/sources.py +154 -0
  79. forgex/sandbox/__init__.py +37 -0
  80. forgex/sandbox/executor.py +418 -0
  81. forgex/sandbox/permissions.py +67 -0
  82. forgex/sandbox/policies.py +172 -0
  83. forgex/schemas/__init__.py +101 -0
  84. forgex/schemas/agent.py +109 -0
  85. forgex/schemas/checkpoint.py +19 -0
  86. forgex/schemas/error.py +125 -0
  87. forgex/schemas/event.py +76 -0
  88. forgex/schemas/plan.py +84 -0
  89. forgex/schemas/project.py +92 -0
  90. forgex/schemas/research.py +25 -0
  91. forgex/schemas/result.py +68 -0
  92. forgex/schemas/task.py +118 -0
  93. forgex/storage/__init__.py +25 -0
  94. forgex/storage/database.py +40 -0
  95. forgex/storage/migrations.py +130 -0
  96. forgex/storage/repositories.py +492 -0
  97. forgex/tools/__init__.py +73 -0
  98. forgex/tools/base.py +212 -0
  99. forgex/tools/dependencies.py +137 -0
  100. forgex/tools/filesystem.py +322 -0
  101. forgex/tools/git.py +239 -0
  102. forgex/tools/project.py +39 -0
  103. forgex/tools/search.py +205 -0
  104. forgex/tools/terminal.py +81 -0
  105. forgex/tools/testing.py +100 -0
  106. forgex/tools/verification.py +174 -0
  107. forgex/utils/__init__.py +15 -0
  108. forgex/utils/logging.py +28 -0
  109. forgex/utils/paths.py +48 -0
  110. forgex/utils/redact.py +44 -0
  111. forgex_cli-0.1.0.dist-info/METADATA +189 -0
  112. forgex_cli-0.1.0.dist-info/RECORD +115 -0
  113. forgex_cli-0.1.0.dist-info/WHEEL +4 -0
  114. forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
  115. forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
forgex/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """FORGEX — Plan. Build. Refine. Verify."""
2
+
3
+ __version__ = "0.1.0"
forgex/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow `python -m forgex`."""
2
+
3
+ from forgex.cli.app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,36 @@
1
+ """Agent package (spec sections 7, 9, 10, 11, 12, 15)."""
2
+
3
+ from forgex.agents.architect import ArchitectAgent, build_planner, plan_project
4
+ from forgex.agents.base import BaseAgent, ToolLoopResult, extract_json, prefetch_files
5
+ from forgex.agents.builder import BuilderAgent, build_builder, execute_task
6
+ from forgex.agents.ensemble import ReviewerAgent, ensemble_review, synthesize
7
+ from forgex.agents.expert_coder import ExpertCoderAgent, build_coder, review_work
8
+ from forgex.agents.supervisor import (
9
+ SupervisorAgent,
10
+ apply_quality_gates,
11
+ build_supervisor,
12
+ supervise,
13
+ )
14
+
15
+ __all__ = [
16
+ "ArchitectAgent",
17
+ "BaseAgent",
18
+ "BuilderAgent",
19
+ "ExpertCoderAgent",
20
+ "ReviewerAgent",
21
+ "SupervisorAgent",
22
+ "ToolLoopResult",
23
+ "apply_quality_gates",
24
+ "build_builder",
25
+ "build_coder",
26
+ "build_planner",
27
+ "build_supervisor",
28
+ "ensemble_review",
29
+ "execute_task",
30
+ "extract_json",
31
+ "plan_project",
32
+ "prefetch_files",
33
+ "review_work",
34
+ "supervise",
35
+ "synthesize",
36
+ ]
@@ -0,0 +1,233 @@
1
+ """Architect agent (spec section 7).
2
+
3
+ Understand → Analyze → Decompose → Research → Plan → Validate.
4
+ Never writes code: read-only tools, official registry research,
5
+ structured plan output, dependency graph validation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+
14
+ from forgex.agents.base import BaseAgent
15
+ from forgex.config.schema import ForgexConfig
16
+ from forgex.models.base import Message
17
+ from forgex.models.router import ModelRouter
18
+ from forgex.orchestration.events import EventRecorder
19
+ from forgex.orchestration.stores import ProjectStores
20
+ from forgex.orchestration.workflow import transition
21
+ from forgex.research.citations import format_notes
22
+ from forgex.research.engine import ResearchEngine
23
+ from forgex.sandbox.permissions import permission_set_for
24
+ from forgex.schemas.agent import AgentRole
25
+ from forgex.schemas.error import AgentOutputError, ProjectError
26
+ from forgex.schemas.event import EventType
27
+ from forgex.schemas.plan import (
28
+ AnalysisResult,
29
+ ArchitectResult,
30
+ ArchitecturePlan,
31
+ specs_to_tasks,
32
+ )
33
+ from forgex.schemas.project import WorkflowStatus
34
+ from forgex.schemas.task import topological_order
35
+ from forgex.tools import ToolCall, ToolExecutor, build_tool_registry, create_context
36
+
37
+ logger = logging.getLogger("forgex.agents.architect")
38
+
39
+
40
+ class ArchitectAgent(BaseAgent):
41
+ role = AgentRole.ARCHITECT
42
+ prompt_name = "architect"
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ router: ModelRouter,
48
+ tools: ToolExecutor,
49
+ research: ResearchEngine,
50
+ events: EventRecorder | None = None,
51
+ run_id: str | None = None,
52
+ ) -> None:
53
+ super().__init__(router=router, events=events, run_id=run_id)
54
+ self._tools = tools
55
+ self._research = research
56
+
57
+ async def _gather_context(self) -> str:
58
+ info_res = await self._tools.execute(ToolCall(name="project_info", arguments={}))
59
+ files_res = await self._tools.execute(ToolCall(name="list_files", arguments={}))
60
+
61
+ brief = f"Repository Overview:\n{info_res.output}\n\nFiles:\n{files_res.output}"
62
+ try:
63
+ from forgex.context.manager import ContextManager
64
+
65
+ symbols = ContextManager(self._tools.context.workspace).symbol_map()
66
+ if symbols:
67
+ brief += "\n\nSymbols:\n" + symbols
68
+ except Exception:
69
+ logger.warning("symbol map unavailable", exc_info=True)
70
+ return brief
71
+
72
+ async def plan(
73
+ self,
74
+ user_request: str,
75
+ *,
76
+ on_stage: Callable[[str], None] | None = None,
77
+ ) -> ArchitectResult:
78
+ if self._events:
79
+ self._events.record(
80
+ EventType.AGENT_STARTED,
81
+ {"agent": self.role.value, "request": user_request},
82
+ run_id=self._run_id,
83
+ )
84
+
85
+ repo_context = await self._gather_context()
86
+
87
+ # Stage 1: Analysis & research topic selection
88
+ if on_stage:
89
+ on_stage("analyzing")
90
+
91
+ stage1_msg = (
92
+ f"<user_request>\n{user_request}\n</user_request>\n\n"
93
+ f"<repository>\n{repo_context}\n</repository>\n\n"
94
+ "Analyze this request and repository. State the goal, requirements, candidate libraries, and research topics."
95
+ )
96
+ analysis: AnalysisResult = await self._generate_structured(
97
+ [Message(role="user", content=stage1_msg)], AnalysisResult
98
+ )
99
+
100
+ # Stage 2: Dependency research
101
+ if on_stage:
102
+ on_stage("researching")
103
+ notes = await self._research.research(analysis.research_topics)
104
+ if self._events:
105
+ self._events.record(
106
+ EventType.RESEARCH_COMPLETED,
107
+ {"topics": [n.topic for n in notes], "available": sum(1 for n in notes if n.available)},
108
+ run_id=self._run_id,
109
+ )
110
+
111
+ # Stage 3: Architecture plan creation
112
+ if on_stage:
113
+ on_stage("planning")
114
+
115
+ research_text = format_notes(notes)
116
+ stage2_msg = (
117
+ f"<user_request>\n{user_request}\n</user_request>\n\n"
118
+ f"<repository>\n{repo_context}\n</repository>\n\n"
119
+ f"<analysis>\nGoal: {analysis.goal}\nRequirements: {', '.join(analysis.requirements)}\nCandidate Libraries: {', '.join(analysis.candidate_libraries)}\n</analysis>\n\n"
120
+ f"<research>\n{research_text}\n</research>\n\n"
121
+ "Produce the final implementation plan adhering to the JSON schema. Decompose into focused, verifiable tasks."
122
+ )
123
+
124
+ plan: ArchitecturePlan = await self._generate_structured(
125
+ [Message(role="user", content=stage2_msg)], ArchitecturePlan
126
+ )
127
+
128
+ # Validate plan
129
+ if not plan.tasks:
130
+ raise AgentOutputError("Architect produced an empty plan (no tasks)")
131
+
132
+ # Validate graph: cycle detection, unknown deps, duplicate IDs
133
+ runtime_tasks = specs_to_tasks(plan.tasks)
134
+ topological_order(runtime_tasks)
135
+
136
+ result = ArchitectResult(plan=plan, research=notes, analysis=analysis)
137
+ if self._events:
138
+ self._events.record(
139
+ EventType.AGENT_COMPLETED,
140
+ {"agent": self.role.value, "tasks": len(plan.tasks)},
141
+ run_id=self._run_id,
142
+ )
143
+ return result
144
+
145
+ async def aclose(self) -> None:
146
+ await super().aclose()
147
+ await self._research.aclose()
148
+
149
+
150
+ def build_planner(
151
+ *,
152
+ config: ForgexConfig,
153
+ workspace: Path,
154
+ stores: ProjectStores,
155
+ router: ModelRouter | None = None,
156
+ run_id: str | None = None,
157
+ ) -> ArchitectAgent:
158
+ router = router or ModelRouter(config, usage=stores.usage)
159
+ tool_ctx = create_context(
160
+ workspace=workspace,
161
+ config=config,
162
+ events=stores.recorder,
163
+ run_id=run_id,
164
+ agent=AgentRole.ARCHITECT,
165
+ )
166
+ tools = ToolExecutor(
167
+ registry=build_tool_registry(),
168
+ permissions=permission_set_for(AgentRole.ARCHITECT),
169
+ context=tool_ctx,
170
+ )
171
+ research = ResearchEngine(config=config)
172
+ return ArchitectAgent(
173
+ router=router,
174
+ tools=tools,
175
+ research=research,
176
+ events=stores.recorder,
177
+ run_id=run_id,
178
+ )
179
+
180
+
181
+ async def plan_project(
182
+ planner: ArchitectAgent,
183
+ *,
184
+ stores: ProjectStores,
185
+ request: str,
186
+ dry_run: bool = False,
187
+ progress: Callable[[str], None] | None = None,
188
+ ) -> ArchitectResult:
189
+ state = stores.state.load()
190
+ if state is None:
191
+ raise ProjectError("Project state missing — run `forgex init` first.")
192
+
193
+ # Reset if from completed/failed
194
+ if state.status in (WorkflowStatus.COMPLETED, WorkflowStatus.FAILED, WorkflowStatus.CANCELLED):
195
+ state.status = WorkflowStatus.INITIALIZING
196
+
197
+ if state.status == WorkflowStatus.PLANNING or state.status == WorkflowStatus.INITIALIZING:
198
+ state.status = transition(state.status, WorkflowStatus.ANALYZING)
199
+
200
+ if not dry_run:
201
+ state.user_request = request
202
+ stores.save_state(state)
203
+
204
+ def on_stage(stage: str) -> None:
205
+ if progress:
206
+ progress(stage)
207
+ if not dry_run:
208
+ if stage == "researching" and state.status == WorkflowStatus.ANALYZING:
209
+ state.status = transition(state.status, WorkflowStatus.RESEARCHING)
210
+ stores.save_state(state)
211
+ elif stage == "planning" and state.status in (WorkflowStatus.RESEARCHING, WorkflowStatus.ANALYZING):
212
+ state.status = transition(state.status, WorkflowStatus.PLANNING)
213
+ stores.save_state(state)
214
+
215
+ result = await planner.plan(request, on_stage=on_stage)
216
+
217
+ if not dry_run:
218
+ state.status = WorkflowStatus.PLANNING
219
+ state.architecture_plan = result.plan
220
+ state.research = result.research
221
+ tasks = specs_to_tasks(result.plan.tasks)
222
+ state.tasks = [t.id for t in tasks]
223
+ state.completed_tasks = []
224
+ state.failed_tasks = []
225
+ stores.save_tasks(tasks, project_id=state.project_id)
226
+ stores.save_state(state)
227
+ stores.recorder.record(
228
+ EventType.PLAN_CREATED,
229
+ {"goal": result.plan.goal, "tasks": len(result.plan.tasks)},
230
+ run_id=state.current_run_id,
231
+ )
232
+
233
+ return result
forgex/agents/base.py ADDED
@@ -0,0 +1,385 @@
1
+ """Agent base classes (spec sections 7, 9, 42, 47, 48, 75).
2
+
3
+ Agents are persistence-free services: they receive a request, tools, and
4
+ a router, and return structured results.
5
+
6
+ The tool loop: bounded multi-turn loop supporting native tool calls
7
+ or JSON protocol fallback. Enforces transcript budgets and secret redaction.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import re
15
+ from abc import ABC
16
+ from collections.abc import Iterable
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Any, TypeVar
20
+
21
+ from pydantic import BaseModel, ValidationError
22
+
23
+ from forgex.models.base import Message
24
+ from forgex.orchestration.events import EventRecorder
25
+ from forgex.prompts import load_prompt
26
+ from forgex.schemas.agent import AgentRole
27
+ from forgex.schemas.error import AgentOutputError, PermissionDeniedError
28
+ from forgex.schemas.event import EventType
29
+ from forgex.tools import ToolExecutor, ToolResult
30
+ from forgex.tools.base import ToolCall as SystemToolCall
31
+ from forgex.tools.filesystem import is_secret_file
32
+ from forgex.utils.paths import PathViolationError, resolve_in_workspace
33
+ from forgex.utils.redact import redact_patterns
34
+
35
+ if TYPE_CHECKING:
36
+ from forgex.models.router import ModelRouter
37
+
38
+ logger = logging.getLogger("forgex.agents")
39
+
40
+ ModelT = TypeVar("ModelT", bound=BaseModel)
41
+
42
+ _FENCE_RE = re.compile(r"```(?:json)?\s*(.+?)\s*```", re.DOTALL)
43
+
44
+ MAX_TOOL_RESULT_CHARS = 8_000
45
+ MAX_PROTOCOL_ERRORS = 3
46
+ MAX_TRANSCRIPT_CHARS = 150_000
47
+ _SOFT_BUDGET_RATIO = 0.75
48
+
49
+ PROTOCOL_MODE_DOC = """
50
+ Tool protocol — this model does not support native tool calls.
51
+ To call a tool, respond with ONLY:
52
+ {"action": "tool", "tool": "<tool name>", "arguments": {<arguments>}}
53
+ The tool result is returned to you as JSON in the next message.
54
+ To finish, respond with ONLY:
55
+ {"action": "done", "result": {<final report matching the schema>}}
56
+
57
+ Available tools (JSON Schemas):
58
+ {tool_schemas}
59
+ """
60
+
61
+
62
+ def extract_json(text: str) -> Any:
63
+ """Extract JSON from model output: raw, fenced, or embedded in prose."""
64
+ text = text.strip()
65
+ try:
66
+ return json.loads(text)
67
+ except ValueError:
68
+ pass
69
+ match = _FENCE_RE.search(text)
70
+ if match:
71
+ try:
72
+ return json.loads(match.group(1))
73
+ except ValueError:
74
+ pass
75
+ for opener, closer in (("{", "}"), ("[", "]")):
76
+ start, end = text.find(opener), text.rfind(closer)
77
+ if start != -1 and end > start:
78
+ try:
79
+ return json.loads(text[start : end + 1])
80
+ except ValueError:
81
+ continue
82
+ raise ValueError("no JSON object found in model output")
83
+
84
+
85
+ def prefetch_files(
86
+ workspace: Path,
87
+ files: Iterable[str],
88
+ *,
89
+ budget: int,
90
+ max_files: int | None = None,
91
+ ) -> str:
92
+ """Prefetch existing files' contents into an instruction block."""
93
+ sections: list[str] = []
94
+ remaining = budget
95
+ for rel in files:
96
+ if remaining <= 0:
97
+ break
98
+ if max_files is not None and len(sections) >= max_files:
99
+ break
100
+ if is_secret_file(Path(rel).name):
101
+ continue
102
+ try:
103
+ path = resolve_in_workspace(workspace, rel)
104
+ except PathViolationError:
105
+ continue
106
+ if not path.is_file():
107
+ continue
108
+ try:
109
+ content = path.read_text(encoding="utf-8", errors="replace")
110
+ except OSError:
111
+ continue
112
+
113
+ content = redact_patterns(content)
114
+ if len(content) > remaining:
115
+ content = content[:remaining] + "\n... [trimmed]"
116
+ sections.append(f"--- {rel} ---\n{content}")
117
+ remaining -= len(content)
118
+ return "\n\n".join(sections)
119
+
120
+
121
+ def _transcript_chars(messages: list[Message]) -> int:
122
+ total = 0
123
+ for m in messages:
124
+ total += len(m.content or "")
125
+ if m.tool_calls:
126
+ total += len(json.dumps(m.tool_calls))
127
+ return total
128
+
129
+
130
+ @dataclass
131
+ class ToolLoopResult:
132
+ report: Any
133
+ rounds: int
134
+ tool_calls_count: int
135
+
136
+
137
+ class BaseAgent(ABC):
138
+ role: AgentRole
139
+ prompt_name: str
140
+
141
+ def __init__(
142
+ self,
143
+ *,
144
+ router: ModelRouter,
145
+ events: EventRecorder | None = None,
146
+ run_id: str | None = None,
147
+ temperature: float = 0.2,
148
+ max_output_attempts: int = 2,
149
+ ) -> None:
150
+ self._router = router
151
+ self._events = events
152
+ self._run_id = run_id
153
+ self.temperature = temperature
154
+ self.max_output_attempts = max_output_attempts
155
+
156
+ def _system_prompt(self, target_schema: type[BaseModel] | None = None) -> str:
157
+ prompt_text = load_prompt(self.prompt_name)
158
+ if target_schema is not None:
159
+ schema_json = json.dumps(target_schema.model_json_schema(), indent=2)
160
+ prompt_text += (
161
+ f"\n\nYou must respond with JSON adhering to this schema:\n```json\n{schema_json}\n```\n"
162
+ )
163
+ return prompt_text
164
+
165
+ async def _generate_structured(
166
+ self,
167
+ messages: list[Message],
168
+ target_schema: type[ModelT],
169
+ *,
170
+ agent_role: str | None = None,
171
+ ) -> ModelT:
172
+ role_key = agent_role or self.role.value
173
+ caps = self._router.capabilities_for(role_key)
174
+ system = self._system_prompt(target_schema)
175
+
176
+ if self._events:
177
+ self._events.record(
178
+ EventType.AGENT_THINKING,
179
+ {"agent": role_key, "stage": "structured_generation"},
180
+ run_id=self._run_id,
181
+ )
182
+
183
+ msgs = list(messages)
184
+ rf = {"type": "json_object"} if caps.supports_json else None
185
+
186
+ for attempt in range(1, self.max_output_attempts + 1):
187
+ resp = await self._router.generate(
188
+ role_key,
189
+ messages=msgs,
190
+ system=system,
191
+ response_format=rf,
192
+ temperature=self.temperature,
193
+ run_id=self._run_id,
194
+ )
195
+ raw = resp.content or ""
196
+ try:
197
+ data = extract_json(raw)
198
+ return target_schema.model_validate(data)
199
+ except (ValueError, ValidationError) as exc:
200
+ if attempt >= self.max_output_attempts:
201
+ raise AgentOutputError(
202
+ f"{self.role.value} failed to produce valid JSON: {exc}\nRaw: {raw[:300]}"
203
+ ) from exc
204
+ logger.warning(
205
+ "%s structured output attempt %d failed (%s); retrying",
206
+ self.role.value,
207
+ attempt,
208
+ exc,
209
+ )
210
+ msgs.append(Message(role="assistant", content=raw))
211
+ msgs.append(
212
+ Message(
213
+ role="user",
214
+ content=f"Your previous response did not match the required JSON schema ({exc}). Please reply with ONLY valid JSON.",
215
+ )
216
+ )
217
+
218
+ raise AgentOutputError(f"{self.role.value} exceeded output attempts")
219
+
220
+ async def _tool_loop(
221
+ self,
222
+ initial_instruction: str,
223
+ tools: ToolExecutor,
224
+ target_schema: type[ModelT],
225
+ *,
226
+ max_rounds: int = 16,
227
+ agent_role: str | None = None,
228
+ ) -> ToolLoopResult:
229
+ role_key = agent_role or self.role.value
230
+ caps = self._router.capabilities_for(role_key)
231
+ system = self._system_prompt(target_schema)
232
+
233
+ messages: list[Message] = [Message(role="user", content=initial_instruction)]
234
+ rounds = 0
235
+ total_tool_calls = 0
236
+ warned = False
237
+
238
+ if caps.supports_tools:
239
+ # Native tool-calling mode
240
+ available_schemas = tools.schemas()
241
+ while rounds < max_rounds:
242
+ rounds += 1
243
+ size = _transcript_chars(messages)
244
+ if size > MAX_TRANSCRIPT_CHARS:
245
+ raise AgentOutputError(f"{self.role.value} exceeded context budget ({MAX_TRANSCRIPT_CHARS} chars)")
246
+ if not warned and size > MAX_TRANSCRIPT_CHARS * _SOFT_BUDGET_RATIO:
247
+ warned = True
248
+ messages.append(
249
+ Message(role="user", content="CONTEXT LIMIT APPROACHING: finish current step and produce your final JSON report now.")
250
+ )
251
+
252
+ if self._events:
253
+ self._events.record(
254
+ EventType.AGENT_THINKING,
255
+ {"agent": role_key, "round": rounds},
256
+ run_id=self._run_id,
257
+ )
258
+
259
+ resp = await self._router.generate(
260
+ role_key,
261
+ messages=messages,
262
+ system=system,
263
+ tools=available_schemas,
264
+ temperature=self.temperature,
265
+ run_id=self._run_id,
266
+ )
267
+
268
+ if resp.tool_calls:
269
+ # Convert to wire format for message history
270
+ tc_dicts = [
271
+ {
272
+ "id": tc.id,
273
+ "type": "function",
274
+ "function": {"name": tc.name, "arguments": tc.arguments},
275
+ }
276
+ for tc in resp.tool_calls
277
+ ]
278
+ messages.append(Message(role="assistant", content=resp.content, tool_calls=tc_dicts))
279
+
280
+ for tc in resp.tool_calls:
281
+ total_tool_calls += 1
282
+ try:
283
+ args = json.loads(tc.arguments) if tc.arguments else {}
284
+ except ValueError:
285
+ args = {}
286
+ t_call = SystemToolCall(id=tc.id, name=tc.name, arguments=args)
287
+ try:
288
+ t_res = await tools.execute(t_call)
289
+ except PermissionDeniedError as exc:
290
+ t_res = ToolResult(ok=False, denied=True, error=str(exc))
291
+ out_text = t_res.output or (f"Error: {t_res.error}" if t_res.error else "done")
292
+ if len(out_text) > MAX_TOOL_RESULT_CHARS:
293
+ out_text = out_text[:MAX_TOOL_RESULT_CHARS] + "\n... [truncated]"
294
+ messages.append(
295
+ Message(role="tool", tool_call_id=tc.id, name=tc.name, content=out_text)
296
+ )
297
+ else:
298
+ # Final report produced
299
+ raw = resp.content or ""
300
+ try:
301
+ data = extract_json(raw)
302
+ report = target_schema.model_validate(data)
303
+ return ToolLoopResult(report=report, rounds=rounds, tool_calls_count=total_tool_calls)
304
+ except (ValueError, ValidationError) as exc:
305
+ # Bounded repair
306
+ messages.append(Message(role="assistant", content=raw))
307
+ messages.append(
308
+ Message(
309
+ role="user",
310
+ content=f"Your final response did not match the JSON schema ({exc}). Please return ONLY the final JSON object.",
311
+ )
312
+ )
313
+ repair_resp = await self._router.generate(
314
+ role_key,
315
+ messages=messages,
316
+ system=system,
317
+ temperature=self.temperature,
318
+ run_id=self._run_id,
319
+ )
320
+ data = extract_json(repair_resp.content or "")
321
+ report = target_schema.model_validate(data)
322
+ return ToolLoopResult(report=report, rounds=rounds, tool_calls_count=total_tool_calls)
323
+
324
+ # Loop limit reached; ask for final report
325
+ final = await self._generate_structured(messages, target_schema, agent_role=role_key)
326
+ return ToolLoopResult(report=final, rounds=rounds, tool_calls_count=total_tool_calls)
327
+
328
+ else:
329
+ # JSON Protocol mode for models without tool support
330
+ protocol_doc = PROTOCOL_MODE_DOC.format(
331
+ tool_schemas=json.dumps(tools.schemas(), indent=2)
332
+ )
333
+ protocol_system = system + "\n\n" + protocol_doc
334
+ protocol_errors = 0
335
+
336
+ while rounds < max_rounds:
337
+ rounds += 1
338
+ size = _transcript_chars(messages)
339
+ if size > MAX_TRANSCRIPT_CHARS:
340
+ raise AgentOutputError(f"{self.role.value} exceeded context budget")
341
+
342
+ resp = await self._router.generate(
343
+ role_key,
344
+ messages=messages,
345
+ system=protocol_system,
346
+ temperature=self.temperature,
347
+ run_id=self._run_id,
348
+ )
349
+ raw = resp.content or ""
350
+
351
+ try:
352
+ action_data = extract_json(raw)
353
+ except ValueError:
354
+ protocol_errors += 1
355
+ if protocol_errors >= MAX_PROTOCOL_ERRORS:
356
+ raise AgentOutputError(f"{self.role.value} protocol errors exceeded: {raw[:200]}")
357
+ messages.append(Message(role="assistant", content=raw))
358
+ messages.append(Message(role="user", content="Please reply with a valid JSON action: {\"action\": \"tool\", ...} or {\"action\": \"done\", ...}."))
359
+ continue
360
+
361
+ action = action_data.get("action")
362
+ if action == "tool":
363
+ tool_name = action_data.get("tool")
364
+ args = action_data.get("arguments") or {}
365
+ total_tool_calls += 1
366
+ t_res = await tools.execute(SystemToolCall(name=tool_name, arguments=args))
367
+ out_text = t_res.output or (f"Error: {t_res.error}" if t_res.error else "done")
368
+ if len(out_text) > MAX_TOOL_RESULT_CHARS:
369
+ out_text = out_text[:MAX_TOOL_RESULT_CHARS] + "\n... [truncated]"
370
+ messages.append(Message(role="assistant", content=raw))
371
+ messages.append(Message(role="user", content=json.dumps({"tool_result": out_text})))
372
+ elif action == "done":
373
+ res_data = action_data.get("result") or {}
374
+ report = target_schema.model_validate(res_data)
375
+ return ToolLoopResult(report=report, rounds=rounds, tool_calls_count=total_tool_calls)
376
+ else:
377
+ protocol_errors += 1
378
+ messages.append(Message(role="assistant", content=raw))
379
+ messages.append(Message(role="user", content="Unknown action. Use 'tool' or 'done'."))
380
+
381
+ final = await self._generate_structured(messages, target_schema, agent_role=role_key)
382
+ return ToolLoopResult(report=final, rounds=rounds, tool_calls_count=total_tool_calls)
383
+
384
+ async def aclose(self) -> None:
385
+ await self._router.aclose()