mycode-coding-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.
Files changed (121) hide show
  1. mycode/__init__.py +0 -0
  2. mycode/adapters/__init__.py +21 -0
  3. mycode/adapters/jsonl.py +692 -0
  4. mycode/agent/__init__.py +25 -0
  5. mycode/agent/events.py +111 -0
  6. mycode/agent/outcome.py +103 -0
  7. mycode/agent/progress.py +373 -0
  8. mycode/agent/runner.py +1481 -0
  9. mycode/application/__init__.py +38 -0
  10. mycode/application/agent_session.py +367 -0
  11. mycode/application/events.py +59 -0
  12. mycode/application/runtime.py +211 -0
  13. mycode/application/sessions.py +180 -0
  14. mycode/cli.py +840 -0
  15. mycode/config.py +355 -0
  16. mycode/context/__init__.py +1 -0
  17. mycode/context/artifacts.py +672 -0
  18. mycode/context/budget.py +752 -0
  19. mycode/context/builder.py +112 -0
  20. mycode/context/compact.py +795 -0
  21. mycode/context/tool_result_format.py +199 -0
  22. mycode/context/tool_result_retention.py +261 -0
  23. mycode/conversation.py +78 -0
  24. mycode/error_handling.py +481 -0
  25. mycode/event_format.py +147 -0
  26. mycode/instructions.py +285 -0
  27. mycode/llm.py +771 -0
  28. mycode/mcp/__init__.py +41 -0
  29. mycode/mcp/client.py +44 -0
  30. mycode/mcp/config.py +207 -0
  31. mycode/mcp/errors.py +302 -0
  32. mycode/mcp/manager.py +339 -0
  33. mycode/mcp/models.py +20 -0
  34. mycode/mcp/result_adapter.py +58 -0
  35. mycode/mcp/tool_adapter.py +145 -0
  36. mycode/mcp/trust.py +313 -0
  37. mycode/memory.py +570 -0
  38. mycode/memory_context.py +245 -0
  39. mycode/messages.py +63 -0
  40. mycode/observability.py +28 -0
  41. mycode/permissions.py +262 -0
  42. mycode/persistence/__init__.py +1 -0
  43. mycode/persistence/filesystem.py +291 -0
  44. mycode/persistence/project_storage.py +208 -0
  45. mycode/persistence/session_lock.py +138 -0
  46. mycode/persistence/session_store.py +503 -0
  47. mycode/presentation/__init__.py +1 -0
  48. mycode/presentation/cli/__init__.py +14 -0
  49. mycode/presentation/cli/confirmer.py +116 -0
  50. mycode/presentation/cli/mcp_trust.py +61 -0
  51. mycode/presentation/cli/presenter.py +320 -0
  52. mycode/presentation/cli/session_menu.py +146 -0
  53. mycode/presentation/cli/subagent_observer.py +124 -0
  54. mycode/presentation/command_format.py +90 -0
  55. mycode/presentation/commands.py +95 -0
  56. mycode/presentation/tui/__init__.py +6 -0
  57. mycode/presentation/tui/app.py +1351 -0
  58. mycode/presentation/tui/interactions.py +253 -0
  59. mycode/presentation/tui/presenter.py +266 -0
  60. mycode/presentation/tui/screens.py +305 -0
  61. mycode/presentation/tui/widgets.py +214 -0
  62. mycode/project.py +22 -0
  63. mycode/prompts.py +181 -0
  64. mycode/reasoning.py +40 -0
  65. mycode/session.py +86 -0
  66. mycode/skills/__init__.py +27 -0
  67. mycode/skills/builtin/database-recovery/SKILL.md +138 -0
  68. mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
  69. mycode/skills/registry.py +295 -0
  70. mycode/skills/state.py +68 -0
  71. mycode/subagents/__init__.py +1 -0
  72. mycode/subagents/audit.py +212 -0
  73. mycode/subagents/concurrency.py +124 -0
  74. mycode/subagents/contracts.py +421 -0
  75. mycode/subagents/delegate.py +80 -0
  76. mycode/subagents/delegation.py +128 -0
  77. mycode/subagents/lifecycle.py +86 -0
  78. mycode/subagents/limits.py +7 -0
  79. mycode/subagents/observability.py +150 -0
  80. mycode/subagents/persistence.py +152 -0
  81. mycode/subagents/profiles.py +184 -0
  82. mycode/subagents/prompts.py +67 -0
  83. mycode/subagents/results.py +178 -0
  84. mycode/subagents/runtime.py +528 -0
  85. mycode/subagents/snapshots.py +211 -0
  86. mycode/subagents/tool_batch.py +260 -0
  87. mycode/tools/__init__.py +81 -0
  88. mycode/tools/base.py +222 -0
  89. mycode/tools/bounds.py +14 -0
  90. mycode/tools/command_executor.py +167 -0
  91. mycode/tools/command_output.py +166 -0
  92. mycode/tools/command_risk.py +596 -0
  93. mycode/tools/defaults.py +59 -0
  94. mycode/tools/edit_file.py +524 -0
  95. mycode/tools/file_mutation.py +30 -0
  96. mycode/tools/glob.py +247 -0
  97. mycode/tools/grep.py +324 -0
  98. mycode/tools/ignore.py +122 -0
  99. mycode/tools/inspect_changes.py +269 -0
  100. mycode/tools/load_skill.py +92 -0
  101. mycode/tools/memory.py +264 -0
  102. mycode/tools/path_permissions.py +78 -0
  103. mycode/tools/patterns.py +48 -0
  104. mycode/tools/permission_metadata.py +27 -0
  105. mycode/tools/process_tree.py +166 -0
  106. mycode/tools/read_file.py +242 -0
  107. mycode/tools/read_skill_resource.py +93 -0
  108. mycode/tools/registry.py +279 -0
  109. mycode/tools/run_command.py +237 -0
  110. mycode/tools/run_skill_script.py +206 -0
  111. mycode/tools/run_validation.py +107 -0
  112. mycode/tools/submit_result.py +93 -0
  113. mycode/tools/text.py +15 -0
  114. mycode/tools/validation_command.py +377 -0
  115. mycode/tools/workspace.py +33 -0
  116. mycode/tools/write_file.py +169 -0
  117. mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
  118. mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
  119. mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  120. mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  121. mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,178 @@
1
+ from collections.abc import Sequence
2
+
3
+ from mycode.subagents.contracts import (
4
+ BoundedResultArgs,
5
+ ExplorerResult,
6
+ ReviewerResult,
7
+ SubAgentPayload,
8
+ SubAgentRole,
9
+ TesterReport,
10
+ TesterResult,
11
+ ValidationExecution,
12
+ )
13
+
14
+
15
+ DEFAULT_MAX_FINAL_PAYLOAD_CHARS = 12000
16
+
17
+
18
+ class ResultCompressionError(ValueError):
19
+ pass
20
+
21
+
22
+ def finalize_submitted_payload(
23
+ role: SubAgentRole,
24
+ submitted: BoundedResultArgs,
25
+ *,
26
+ validation_executions: Sequence[ValidationExecution] = (),
27
+ max_chars: int = DEFAULT_MAX_FINAL_PAYLOAD_CHARS,
28
+ ) -> SubAgentPayload:
29
+ if max_chars < 1:
30
+ raise ValueError("max_chars must be at least 1.")
31
+
32
+ if role == "explorer" and isinstance(submitted, ExplorerResult):
33
+ payload: SubAgentPayload = submitted
34
+ elif role == "tester" and isinstance(submitted, TesterReport):
35
+ payload = TesterResult(
36
+ status=submitted.status,
37
+ summary=submitted.summary,
38
+ executions=list(validation_executions),
39
+ failure_summary=submitted.failure_summary,
40
+ blocked_reason=submitted.blocked_reason,
41
+ uncertainties=submitted.uncertainties,
42
+ truncated=submitted.truncated,
43
+ omitted_count=submitted.omitted_count,
44
+ )
45
+ elif role == "reviewer" and isinstance(submitted, ReviewerResult):
46
+ payload = submitted
47
+ else:
48
+ raise ValueError(f"Submitted result does not match SubAgent role: {role}")
49
+
50
+ return compress_subagent_payload(payload, max_chars=max_chars)
51
+
52
+
53
+ def compress_subagent_payload(
54
+ payload: SubAgentPayload,
55
+ *,
56
+ max_chars: int = DEFAULT_MAX_FINAL_PAYLOAD_CHARS,
57
+ ) -> SubAgentPayload:
58
+ if max_chars < 1:
59
+ raise ValueError("max_chars must be at least 1.")
60
+ if _serialized_chars(payload) <= max_chars:
61
+ return payload
62
+
63
+ candidate = _drop_uncertainties(payload, max_chars=max_chars)
64
+ if _serialized_chars(candidate) <= max_chars:
65
+ return candidate
66
+
67
+ if isinstance(candidate, ExplorerResult):
68
+ candidate = _compress_explorer(candidate, max_chars=max_chars)
69
+ elif isinstance(candidate, ReviewerResult):
70
+ candidate = _compress_reviewer(candidate, max_chars=max_chars)
71
+ else:
72
+ candidate = _compress_tester(candidate, max_chars=max_chars)
73
+
74
+ if _serialized_chars(candidate) > max_chars:
75
+ raise ResultCompressionError(
76
+ "Structured result cannot fit the final payload budget without "
77
+ "removing protected summary, location, command, exit-status, or "
78
+ "error fields."
79
+ )
80
+ return candidate
81
+
82
+
83
+ def _drop_uncertainties(
84
+ payload: SubAgentPayload,
85
+ *,
86
+ max_chars: int,
87
+ ) -> SubAgentPayload:
88
+ candidate = payload
89
+ while candidate.uncertainties and _serialized_chars(candidate) > max_chars:
90
+ candidate = _copy_with_omissions(
91
+ candidate,
92
+ uncertainties=candidate.uncertainties[:-1],
93
+ )
94
+ return candidate
95
+
96
+
97
+ def _compress_explorer(
98
+ payload: ExplorerResult,
99
+ *,
100
+ max_chars: int,
101
+ ) -> ExplorerResult:
102
+ candidate = payload
103
+ minimum_findings = 1 if candidate.findings else 0
104
+ while (
105
+ len(candidate.findings) > minimum_findings
106
+ and _serialized_chars(candidate) > max_chars
107
+ ):
108
+ candidate = _copy_with_omissions(
109
+ candidate,
110
+ findings=candidate.findings[:-1],
111
+ )
112
+ return candidate
113
+
114
+
115
+ def _compress_reviewer(
116
+ payload: ReviewerResult,
117
+ *,
118
+ max_chars: int,
119
+ ) -> ReviewerResult:
120
+ candidate = payload
121
+ minimum_findings = 1 if candidate.findings else 0
122
+ severity_priority = {"critical": 0, "high": 1, "medium": 2, "low": 3}
123
+ while (
124
+ len(candidate.findings) > minimum_findings
125
+ and _serialized_chars(candidate) > max_chars
126
+ ):
127
+ removable_index = max(
128
+ range(len(candidate.findings)),
129
+ key=lambda index: (severity_priority[candidate.findings[index].severity], index),
130
+ )
131
+ findings = list(candidate.findings)
132
+ del findings[removable_index]
133
+ candidate = _copy_with_omissions(candidate, findings=findings)
134
+ return candidate
135
+
136
+
137
+ def _compress_tester(
138
+ payload: TesterResult,
139
+ *,
140
+ max_chars: int,
141
+ ) -> TesterResult:
142
+ candidate = payload
143
+ while len(candidate.executions) > 1 and _serialized_chars(candidate) > max_chars:
144
+ removable_index = _tester_execution_to_remove(candidate)
145
+ executions = list(candidate.executions)
146
+ del executions[removable_index]
147
+ candidate = _copy_with_omissions(candidate, executions=executions)
148
+ return candidate
149
+
150
+
151
+ def _tester_execution_to_remove(payload: TesterResult) -> int:
152
+ failed_indexes = [
153
+ index
154
+ for index, execution in enumerate(payload.executions)
155
+ if execution.timed_out
156
+ or execution.exit_code is None
157
+ or execution.exit_code != 0
158
+ ]
159
+ successful_indexes = [
160
+ index for index in range(len(payload.executions)) if index not in failed_indexes
161
+ ]
162
+ if payload.status in {"failed", "blocked"} and successful_indexes:
163
+ return successful_indexes[-1]
164
+ if payload.status == "passed":
165
+ return successful_indexes[-1]
166
+ return len(payload.executions) - 1
167
+
168
+
169
+ def _copy_with_omissions(payload, **updates):
170
+ data = payload.model_dump()
171
+ data.update(updates)
172
+ data["truncated"] = True
173
+ data["omitted_count"] = payload.omitted_count + 1
174
+ return type(payload).model_validate(data)
175
+
176
+
177
+ def _serialized_chars(payload: SubAgentPayload) -> int:
178
+ return len(payload.model_dump_json())
@@ -0,0 +1,528 @@
1
+ from collections.abc import Callable, Iterator
2
+ from dataclasses import dataclass, field
3
+ from datetime import UTC, datetime
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path
7
+ from tempfile import TemporaryDirectory
8
+
9
+ from mycode.context.artifacts import ToolResultArtifactStore
10
+ from uuid import uuid4
11
+
12
+ from mycode.agent.events import AgentEvent, AgentModelResponse
13
+ from mycode.context.budget import ContextBudget, MemoryContextStats, TokenEstimator, TokenUsage
14
+ from mycode.conversation import Conversation
15
+ from mycode.instructions import InstructionBundle, load_instruction_bundle
16
+ from mycode.llm import LLMClient
17
+ from mycode.memory import MemoryStore
18
+ from mycode.memory_context import MemoryContextSelector, MemoryRecallPolicy
19
+ from mycode.messages import Message
20
+ from mycode.observability import ObservationSink
21
+ from mycode.permissions import Confirmer, RejectingConfirmer
22
+ from mycode.agent.runner import (
23
+ DEFAULT_REPEATED_TOOL_CALL_LIMIT,
24
+ AgentRunner,
25
+ )
26
+ from mycode.subagents.contracts import (
27
+ SubAgentEnvelopeStatus,
28
+ SubAgentResult,
29
+ SubAgentStopReason,
30
+ SubAgentTask,
31
+ )
32
+ from mycode.subagents.concurrency import SubAgentInteractionGate
33
+ from mycode.subagents.lifecycle import (
34
+ RunTracker,
35
+ StateTransitionHandler,
36
+ SubAgentStateTransition,
37
+ TrackingConfirmer,
38
+ )
39
+ from mycode.subagents.limits import MAX_VALIDATION_EXECUTIONS
40
+ from mycode.subagents.observability import (
41
+ SubAgentObserver,
42
+ SynchronizedSubAgentObserver,
43
+ )
44
+ from mycode.subagents.profiles import create_subagent_tool_registry, get_agent_profile
45
+ from mycode.subagents.prompts import build_subagent_system_prompt
46
+ from mycode.subagents.results import (
47
+ DEFAULT_MAX_FINAL_PAYLOAD_CHARS,
48
+ )
49
+ from mycode.subagents.snapshots import (
50
+ FrozenMemoryRecallProvider,
51
+ SubAgentSnapshotMetadata,
52
+ create_runtime_context_snapshot,
53
+ )
54
+ from mycode.subagents.tool_batch import SubAgentToolBatchHandler
55
+ from mycode.tools.workspace import Workspace
56
+
57
+
58
+ DEFAULT_MAX_VALIDATION_CALLS = 20
59
+ DEFAULT_SUBAGENT_MAX_TURNS = 20
60
+ DEFAULT_SUBAGENT_NEAR_LIMIT_REMAINING_TURNS = 3
61
+ MAX_RUNTIME_ERROR_CHARS = 2000
62
+
63
+ InstructionLoader = Callable[[Path, Path], InstructionBundle]
64
+ LLMClientFactory = Callable[[], LLMClient]
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class SubAgentModelContextStats:
69
+ estimated_input_tokens: int
70
+ max_input_tokens: int
71
+ selected_message_count: int
72
+ original_message_count: int
73
+ compressed_tool_result_count: int
74
+ memory_stats: MemoryContextStats | None
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class SubAgentExecution:
79
+ result: SubAgentResult
80
+ transitions: tuple[SubAgentStateTransition, ...]
81
+ snapshot: SubAgentSnapshotMetadata | None
82
+ context: SubAgentModelContextStats | None
83
+ token_usage: TokenUsage | None
84
+ conversation_message_count: int
85
+ tool_call_count: int
86
+ validation_execution_count: int
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class SubAgentRuntime:
91
+ workspace: Workspace
92
+ llm_client_factory: LLMClientFactory
93
+ confirmer: Confirmer | None = None
94
+ memory_store: MemoryStore | None = None
95
+ memory_recall_policy: MemoryRecallPolicy = field(default_factory=MemoryRecallPolicy)
96
+ context_budget: ContextBudget = field(default_factory=ContextBudget)
97
+ observability_sink: ObservationSink | None = field(default=None, repr=False)
98
+ max_turns: int = DEFAULT_SUBAGENT_MAX_TURNS
99
+ near_limit_remaining_turns: int | None = (
100
+ DEFAULT_SUBAGENT_NEAR_LIMIT_REMAINING_TURNS
101
+ )
102
+ repeated_tool_call_limit: int = DEFAULT_REPEATED_TOOL_CALL_LIMIT
103
+ max_final_payload_chars: int = DEFAULT_MAX_FINAL_PAYLOAD_CHARS
104
+ max_validation_calls: int = DEFAULT_MAX_VALIDATION_CALLS
105
+ working_directory: Path | None = None
106
+ instruction_loader: InstructionLoader = field(
107
+ default=lambda root, working: load_instruction_bundle(
108
+ root,
109
+ working_directory=working,
110
+ )
111
+ )
112
+ clock: Callable[[], datetime] = field(default=lambda: datetime.now(UTC))
113
+ run_id_factory: Callable[[], str] = field(default=lambda: uuid4().hex)
114
+ interaction_gate: SubAgentInteractionGate = field(
115
+ default_factory=SubAgentInteractionGate,
116
+ repr=False,
117
+ compare=False,
118
+ )
119
+
120
+ def __post_init__(self) -> None:
121
+ if self.max_turns < 1:
122
+ raise ValueError("max_turns must be at least 1.")
123
+ if self.near_limit_remaining_turns is not None and not (
124
+ 0 < self.near_limit_remaining_turns < self.max_turns
125
+ ):
126
+ raise ValueError(
127
+ "near_limit_remaining_turns must be greater than 0 and less than "
128
+ "max_turns."
129
+ )
130
+ if self.repeated_tool_call_limit < 1:
131
+ raise ValueError("repeated_tool_call_limit must be at least 1.")
132
+ if self.max_final_payload_chars < 1:
133
+ raise ValueError("max_final_payload_chars must be at least 1.")
134
+ if self.max_validation_calls < 1:
135
+ raise ValueError("max_validation_calls must be at least 1.")
136
+ if self.max_validation_calls > MAX_VALIDATION_EXECUTIONS:
137
+ raise ValueError(
138
+ "max_validation_calls must not exceed "
139
+ f"{MAX_VALIDATION_EXECUTIONS}."
140
+ )
141
+ if (
142
+ self.memory_store is not None
143
+ and self.memory_store.project.workspace_root != self.workspace.root
144
+ ):
145
+ raise ValueError(
146
+ "memory_store project must match the SubAgent workspace."
147
+ )
148
+ working_directory = self._resolved_working_directory()
149
+ if not working_directory.is_relative_to(self.workspace.root):
150
+ raise ValueError("working_directory must be inside the workspace.")
151
+
152
+ def execute(
153
+ self,
154
+ task: SubAgentTask,
155
+ *,
156
+ on_state_transition: StateTransitionHandler | None = None,
157
+ observer: SubAgentObserver | None = None,
158
+ ) -> SubAgentExecution:
159
+ run_observer = (
160
+ None
161
+ if observer is None
162
+ else SynchronizedSubAgentObserver(
163
+ observer,
164
+ self.interaction_gate,
165
+ )
166
+ )
167
+ run_state_transition = (
168
+ None
169
+ if on_state_transition is None
170
+ else lambda transition: self.interaction_gate.run(
171
+ lambda: on_state_transition(transition)
172
+ )
173
+ )
174
+ run_id = self.interaction_gate.run(self.run_id_factory)
175
+ tracker = RunTracker(
176
+ run_id=run_id,
177
+ role=task.role,
178
+ clock=self._now,
179
+ handler=_state_transition_handler(
180
+ task,
181
+ on_state_transition=run_state_transition,
182
+ observer=run_observer,
183
+ ),
184
+ )
185
+ snapshot_metadata: SubAgentSnapshotMetadata | None = None
186
+ runner: AgentRunner | None = None
187
+ batch_handler: SubAgentToolBatchHandler | None = None
188
+ artifact_directory: TemporaryDirectory | None = None
189
+
190
+ try:
191
+ tracker.transition("running", "run_started")
192
+ instruction_bundle = self.instruction_loader(
193
+ self.workspace.root,
194
+ self._resolved_working_directory(),
195
+ )
196
+ memory_recall = None
197
+ if self.memory_store is not None:
198
+ memory_recall = MemoryContextSelector(
199
+ self.memory_store,
200
+ policy=self.memory_recall_policy,
201
+ token_estimator=TokenEstimator(),
202
+ ).recall(_memory_query(task))
203
+ runtime_snapshot = create_runtime_context_snapshot(
204
+ instruction_bundle,
205
+ memory_recall=memory_recall,
206
+ loaded_at=self._now(),
207
+ )
208
+ snapshot_metadata = runtime_snapshot.metadata
209
+ if run_observer is not None:
210
+ run_observer.on_snapshot(
211
+ task,
212
+ run_id,
213
+ snapshot_metadata,
214
+ snapshot_metadata.loaded_at,
215
+ )
216
+
217
+ profile = get_agent_profile(task.role)
218
+ batch_handler = SubAgentToolBatchHandler(
219
+ role=task.role,
220
+ max_final_payload_chars=self.max_final_payload_chars,
221
+ max_validation_calls=self.max_validation_calls,
222
+ audit_handler=(
223
+ None
224
+ if run_observer is None
225
+ else lambda audit: run_observer.on_tool_audit(
226
+ task,
227
+ run_id,
228
+ audit,
229
+ self._now(),
230
+ )
231
+ ),
232
+ )
233
+ tracking_confirmer = TrackingConfirmer(
234
+ delegate=(
235
+ RejectingConfirmer() if self.confirmer is None else self.confirmer
236
+ ),
237
+ tracker=tracker,
238
+ interaction_gate=self.interaction_gate,
239
+ )
240
+ registry = create_subagent_tool_registry(
241
+ profile,
242
+ self.workspace,
243
+ confirmer=tracking_confirmer,
244
+ result_validator=batch_handler.validate_submission,
245
+ )
246
+ conversation = Conversation.from_messages(
247
+ [
248
+ Message(
249
+ role="system",
250
+ content=build_subagent_system_prompt(
251
+ profile,
252
+ registry,
253
+ project_instructions=runtime_snapshot.project_instructions,
254
+ ),
255
+ )
256
+ ]
257
+ )
258
+ artifact_directory = TemporaryDirectory(prefix="mycode-subagent-")
259
+ runner = AgentRunner(
260
+ llm_client=self.interaction_gate.run(self.llm_client_factory),
261
+ tool_registry=registry,
262
+ conversation=conversation,
263
+ max_turns=self.max_turns,
264
+ near_limit_remaining_turns=self.near_limit_remaining_turns,
265
+ near_limit_prompt=(
266
+ None
267
+ if self.near_limit_remaining_turns is None
268
+ else profile.near_limit_prompt
269
+ ),
270
+ repeated_tool_call_limit=self.repeated_tool_call_limit,
271
+ finalize_on_max_turns=False,
272
+ context_budget=self.context_budget,
273
+ tool_result_artifact_store=ToolResultArtifactStore(
274
+ root=Path(artifact_directory.name),
275
+ threshold_chars=self.context_budget.tool_result_compression_threshold_chars,
276
+ ),
277
+ token_estimator=TokenEstimator(),
278
+ memory_context_selector=(
279
+ None
280
+ if runtime_snapshot.memory_recall is None
281
+ else FrozenMemoryRecallProvider(runtime_snapshot.memory_recall)
282
+ ),
283
+ tool_batch_handler=batch_handler,
284
+ observability_sink=self.observability_sink,
285
+ observability_scope="subagent",
286
+ observability_run_id=run_id,
287
+ )
288
+ response = _collect_agent_events(runner.run(_task_message(task)))
289
+ result = _result_from_response(
290
+ run_id=run_id,
291
+ task=task,
292
+ response=response,
293
+ batch_handler=batch_handler,
294
+ )
295
+ tracker.transition(result.status, result.stop_reason)
296
+ except (KeyboardInterrupt, SystemExit):
297
+ tracker.transition_once("interrupted", "interrupted")
298
+ raise
299
+ except Exception as error:
300
+ result = _failed_result(
301
+ run_id=run_id,
302
+ task=task,
303
+ status="failed",
304
+ stop_reason="runtime_error",
305
+ summary="SubAgent runtime failed before producing a valid result.",
306
+ error=_safe_error(error),
307
+ )
308
+ tracker.transition_once("failed", "runtime_error")
309
+
310
+ finally:
311
+ if artifact_directory is not None:
312
+ artifact_directory.cleanup()
313
+
314
+ execution = SubAgentExecution(
315
+ result=result,
316
+ transitions=tuple(tracker.transitions),
317
+ snapshot=snapshot_metadata,
318
+ context=_context_stats(runner),
319
+ token_usage=None if runner is None else runner.run_token_usage,
320
+ conversation_message_count=(
321
+ 0 if runner is None else len(runner.conversation.get_messages())
322
+ ),
323
+ tool_call_count=0 if batch_handler is None else batch_handler.tool_call_count,
324
+ validation_execution_count=(
325
+ 0
326
+ if batch_handler is None
327
+ else len(batch_handler.validation_executions)
328
+ ),
329
+ )
330
+ if run_observer is not None:
331
+ run_observer.on_result(task, execution, self._now())
332
+ return execution
333
+
334
+ def _resolved_working_directory(self) -> Path:
335
+ if self.working_directory is None:
336
+ return self.workspace.root
337
+ path = self.working_directory
338
+ if not path.is_absolute():
339
+ path = self.workspace.root / path
340
+ return path.resolve(strict=False)
341
+
342
+ def _now(self) -> datetime:
343
+ return self.interaction_gate.run(self.clock)
344
+
345
+
346
+ def _state_transition_handler(
347
+ task: SubAgentTask,
348
+ *,
349
+ on_state_transition: StateTransitionHandler | None,
350
+ observer: SubAgentObserver | None,
351
+ ) -> StateTransitionHandler | None:
352
+ if on_state_transition is None and observer is None:
353
+ return None
354
+
355
+ def handle(transition: SubAgentStateTransition) -> None:
356
+ if on_state_transition is not None:
357
+ on_state_transition(transition)
358
+ if observer is not None:
359
+ observer.on_state(task, transition)
360
+
361
+ return handle
362
+
363
+
364
+ def _collect_agent_events(events: Iterator[AgentEvent]) -> AgentModelResponse:
365
+ current_turn_content: list[str] = []
366
+ current_error: str | None = None
367
+ final_content = ""
368
+ stop_reason = None
369
+
370
+ for event in events:
371
+ if event.type in {"turn", "model_start"}:
372
+ current_turn_content = []
373
+ current_error = None
374
+ elif event.type == "text_delta":
375
+ current_turn_content.append(event.content)
376
+ elif event.type == "error":
377
+ if event.error is not None:
378
+ current_error = event.error
379
+ elif event.type == "stop":
380
+ stop_reason = event.stop_reason or "model_error"
381
+ final_content = "".join(current_turn_content)
382
+ if final_content:
383
+ continue
384
+ if stop_reason in {"model_error", "context_overflow"} and current_error:
385
+ final_content = current_error
386
+ elif event.content:
387
+ final_content = event.content
388
+
389
+ return AgentModelResponse(
390
+ content=final_content,
391
+ stop_reason=stop_reason or "model_error",
392
+ )
393
+
394
+
395
+ def _result_from_response(
396
+ *,
397
+ run_id: str,
398
+ task: SubAgentTask,
399
+ response: AgentModelResponse,
400
+ batch_handler: SubAgentToolBatchHandler,
401
+ ) -> SubAgentResult:
402
+ if response.stop_reason == "control_tool" and batch_handler.submitted_payload is not None:
403
+ return SubAgentResult(
404
+ run_id=run_id,
405
+ role=task.role,
406
+ status="completed",
407
+ stop_reason="submitted",
408
+ summary=batch_handler.submitted_payload.summary,
409
+ payload=batch_handler.submitted_payload,
410
+ )
411
+
412
+ if response.stop_reason == "max_turns":
413
+ stop_reason: SubAgentStopReason = (
414
+ "invalid_result" if batch_handler.submission_attempted else "max_turns"
415
+ )
416
+ elif response.stop_reason in {
417
+ "model_error",
418
+ "context_overflow",
419
+ "repeated_tool_call",
420
+ }:
421
+ stop_reason = response.stop_reason
422
+ else:
423
+ stop_reason = "invalid_result"
424
+
425
+ summary = {
426
+ "max_turns": "SubAgent reached its maximum number of turns.",
427
+ "invalid_result": "SubAgent did not submit a valid structured result.",
428
+ "model_error": "SubAgent model request failed.",
429
+ "context_overflow": "SubAgent context exceeded its configured budget.",
430
+ "repeated_tool_call": "SubAgent repeated the same tool call too many times.",
431
+ }[stop_reason]
432
+ return _failed_result(
433
+ run_id=run_id,
434
+ task=task,
435
+ status="failed",
436
+ stop_reason=stop_reason,
437
+ summary=summary,
438
+ error=(
439
+ _omitted_model_content(response.content, fallback=summary)
440
+ if stop_reason == "invalid_result"
441
+ else _safe_text(response.content, fallback=summary)
442
+ ),
443
+ )
444
+
445
+
446
+ def _failed_result(
447
+ *,
448
+ run_id: str,
449
+ task: SubAgentTask,
450
+ status: SubAgentEnvelopeStatus,
451
+ stop_reason: SubAgentStopReason,
452
+ summary: str,
453
+ error: str,
454
+ ) -> SubAgentResult:
455
+ return SubAgentResult(
456
+ run_id=run_id,
457
+ role=task.role,
458
+ status=status,
459
+ stop_reason=stop_reason,
460
+ summary=summary,
461
+ error=error,
462
+ )
463
+
464
+
465
+ def _context_stats(runner: AgentRunner | None) -> SubAgentModelContextStats | None:
466
+ if runner is None or runner.last_model_context is None:
467
+ return None
468
+ context = runner.last_model_context
469
+ return SubAgentModelContextStats(
470
+ estimated_input_tokens=context.estimate.estimated_input_tokens,
471
+ max_input_tokens=context.estimate.max_input_tokens,
472
+ selected_message_count=context.selected_message_count,
473
+ original_message_count=context.original_message_count,
474
+ compressed_tool_result_count=context.compressed_tool_result_count,
475
+ memory_stats=context.memory_stats,
476
+ )
477
+
478
+
479
+ def _task_message(task: SubAgentTask) -> str:
480
+ return (
481
+ "Complete this delegated task and submit the role-specific structured result.\n\n"
482
+ "<delegated_task_json>\n"
483
+ + json.dumps(task.model_dump(), ensure_ascii=False, indent=2)
484
+ + "\n</delegated_task_json>"
485
+ )
486
+
487
+
488
+ def _memory_query(task: SubAgentTask) -> str:
489
+ return "\n".join(
490
+ part
491
+ for part in (
492
+ task.objective,
493
+ task.context,
494
+ " ".join(task.scope_paths),
495
+ )
496
+ if part
497
+ )
498
+
499
+
500
+ def _safe_error(error: Exception) -> str:
501
+ return _safe_text(
502
+ f"{type(error).__name__}: {error}",
503
+ fallback=f"{type(error).__name__} with no safe message.",
504
+ )
505
+
506
+
507
+ def _omitted_model_content(content: str, *, fallback: str) -> str:
508
+ normalized = content.strip()
509
+ if not normalized:
510
+ return fallback
511
+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
512
+ return (
513
+ f"{fallback} Raw assistant content was not returned to the parent "
514
+ f"({len(normalized)} characters, sha256={digest})."
515
+ )
516
+
517
+
518
+ def _safe_text(content: str, *, fallback: str) -> str:
519
+ normalized = content.strip()
520
+ if not normalized:
521
+ return fallback
522
+ if len(normalized) <= MAX_RUNTIME_ERROR_CHARS:
523
+ return normalized
524
+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
525
+ return (
526
+ f"{fallback} Original message omitted because it exceeded the runtime "
527
+ f"error limit ({len(normalized)} characters, sha256={digest})."
528
+ )