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,421 @@
1
+ from typing import ClassVar, Literal, Self
2
+
3
+ from pydantic import Field, field_validator, model_validator
4
+
5
+ from mycode.subagents.limits import (
6
+ MAX_VALIDATION_COMMAND_CHARS,
7
+ MAX_VALIDATION_COMMAND_PART_CHARS,
8
+ MAX_VALIDATION_COMMAND_PARTS,
9
+ MAX_VALIDATION_EXECUTIONS,
10
+ )
11
+ from mycode.tools.base import ToolArgs
12
+
13
+
14
+ MAX_TASK_CHARS = 4000
15
+ MAX_CONTEXT_CHARS = 4000
16
+ MAX_SUMMARY_CHARS = 2000
17
+ MAX_DETAIL_CHARS = 2000
18
+ MAX_PATH_CHARS = 500
19
+ MAX_SCOPE_ITEMS = 50
20
+ MAX_FINDINGS = 40
21
+ MAX_UNCERTAINTIES = 20
22
+
23
+ SubAgentRole = Literal["explorer", "tester", "reviewer"]
24
+
25
+
26
+ class SubAgentTask(ToolArgs):
27
+ role: SubAgentRole
28
+ objective: str = Field(min_length=1, max_length=MAX_TASK_CHARS)
29
+ context: str = Field(default="", max_length=MAX_CONTEXT_CHARS)
30
+ scope_paths: list[str] = Field(default_factory=list, max_length=MAX_SCOPE_ITEMS)
31
+
32
+ @field_validator("objective")
33
+ @classmethod
34
+ def objective_must_not_be_blank(cls, value: str) -> str:
35
+ return _required_text(value, field_name="objective")
36
+
37
+ @field_validator("context")
38
+ @classmethod
39
+ def normalize_context(cls, value: str) -> str:
40
+ return value.strip()
41
+
42
+ @field_validator("scope_paths")
43
+ @classmethod
44
+ def validate_scope_paths(cls, value: list[str]) -> list[str]:
45
+ return _bounded_text_list(value, field_name="scope_paths", max_chars=MAX_PATH_CHARS)
46
+
47
+
48
+ class BoundedResultArgs(ToolArgs):
49
+ truncated: bool = False
50
+ omitted_count: int = Field(default=0, ge=0)
51
+
52
+ @model_validator(mode="after")
53
+ def truncation_metadata_must_match(self) -> Self:
54
+ if self.truncated != (self.omitted_count > 0):
55
+ raise ValueError(
56
+ "truncated must be true exactly when omitted_count is above 0."
57
+ )
58
+ return self
59
+
60
+
61
+ class ExplorerFinding(ToolArgs):
62
+ path: str = Field(min_length=1, max_length=MAX_PATH_CHARS)
63
+ line: int | None = Field(default=None, ge=1)
64
+ symbol: str | None = Field(default=None, max_length=200)
65
+ claim: str = Field(min_length=1, max_length=MAX_DETAIL_CHARS)
66
+ evidence: str = Field(min_length=1, max_length=MAX_DETAIL_CHARS)
67
+
68
+ @field_validator("path", "claim", "evidence")
69
+ @classmethod
70
+ def required_fields_must_not_be_blank(cls, value: str, info) -> str:
71
+ return _required_text(value, field_name=info.field_name)
72
+
73
+ @field_validator("symbol")
74
+ @classmethod
75
+ def normalize_symbol(cls, value: str | None) -> str | None:
76
+ return _optional_text(value)
77
+
78
+
79
+ ExplorerStatus = Literal["completed", "partial", "blocked", "no_match"]
80
+
81
+
82
+ class ExplorerResult(BoundedResultArgs):
83
+ status: ExplorerStatus
84
+ summary: str = Field(min_length=1, max_length=MAX_SUMMARY_CHARS)
85
+ searched_scope: list[str] = Field(min_length=1, max_length=MAX_SCOPE_ITEMS)
86
+ findings: list[ExplorerFinding] = Field(default_factory=list, max_length=MAX_FINDINGS)
87
+ uncertainties: list[str] = Field(default_factory=list, max_length=MAX_UNCERTAINTIES)
88
+ blocked_reason: str | None = Field(default=None, max_length=MAX_DETAIL_CHARS)
89
+
90
+ @field_validator("summary")
91
+ @classmethod
92
+ def summary_must_not_be_blank(cls, value: str) -> str:
93
+ return _required_text(value, field_name="summary")
94
+
95
+ @field_validator("searched_scope")
96
+ @classmethod
97
+ def validate_searched_scope(cls, value: list[str]) -> list[str]:
98
+ return _bounded_text_list(
99
+ value,
100
+ field_name="searched_scope",
101
+ max_chars=MAX_PATH_CHARS,
102
+ )
103
+
104
+ @field_validator("uncertainties")
105
+ @classmethod
106
+ def validate_uncertainties(cls, value: list[str]) -> list[str]:
107
+ return _bounded_text_list(
108
+ value,
109
+ field_name="uncertainties",
110
+ max_chars=MAX_DETAIL_CHARS,
111
+ )
112
+
113
+ @field_validator("blocked_reason")
114
+ @classmethod
115
+ def normalize_blocked_reason(cls, value: str | None) -> str | None:
116
+ return _optional_text(value)
117
+
118
+ @model_validator(mode="after")
119
+ def status_must_match_findings(self) -> Self:
120
+ if self.status == "completed" and not self.findings:
121
+ raise ValueError("completed Explorer results require at least one finding.")
122
+ if self.status == "no_match" and self.findings:
123
+ raise ValueError("no_match Explorer results must not contain findings.")
124
+ if self.status == "blocked" and self.blocked_reason is None:
125
+ raise ValueError("blocked Explorer results require blocked_reason.")
126
+ if self.status == "partial" and not self.findings and self.blocked_reason is None:
127
+ raise ValueError(
128
+ "partial Explorer results require a finding or blocked_reason."
129
+ )
130
+ return self
131
+
132
+
133
+ TesterStatus = Literal["passed", "failed", "blocked"]
134
+
135
+
136
+ class TesterReport(BoundedResultArgs):
137
+ __test__: ClassVar[bool] = False
138
+
139
+ status: TesterStatus
140
+ summary: str = Field(min_length=1, max_length=MAX_SUMMARY_CHARS)
141
+ failure_summary: str | None = Field(default=None, max_length=MAX_DETAIL_CHARS)
142
+ blocked_reason: str | None = Field(default=None, max_length=MAX_DETAIL_CHARS)
143
+ uncertainties: list[str] = Field(default_factory=list, max_length=MAX_UNCERTAINTIES)
144
+
145
+ @field_validator("summary")
146
+ @classmethod
147
+ def summary_must_not_be_blank(cls, value: str) -> str:
148
+ return _required_text(value, field_name="summary")
149
+
150
+ @field_validator("failure_summary", "blocked_reason")
151
+ @classmethod
152
+ def normalize_optional_details(cls, value: str | None) -> str | None:
153
+ return _optional_text(value)
154
+
155
+ @field_validator("uncertainties")
156
+ @classmethod
157
+ def validate_uncertainties(cls, value: list[str]) -> list[str]:
158
+ return _bounded_text_list(
159
+ value,
160
+ field_name="uncertainties",
161
+ max_chars=MAX_DETAIL_CHARS,
162
+ )
163
+
164
+ @model_validator(mode="after")
165
+ def status_must_match_details(self) -> Self:
166
+ if self.status == "failed" and self.failure_summary is None:
167
+ raise ValueError("failed Tester reports require failure_summary.")
168
+ if self.status == "blocked" and self.blocked_reason is None:
169
+ raise ValueError("blocked Tester reports require blocked_reason.")
170
+ if self.status == "passed" and (
171
+ self.failure_summary is not None or self.blocked_reason is not None
172
+ ):
173
+ raise ValueError(
174
+ "passed Tester reports cannot include failure_summary or blocked_reason."
175
+ )
176
+ return self
177
+
178
+
179
+ class ValidationExecution(ToolArgs):
180
+ command: list[str] = Field(
181
+ min_length=1,
182
+ max_length=MAX_VALIDATION_COMMAND_PARTS,
183
+ )
184
+ cwd: str = Field(min_length=1, max_length=MAX_PATH_CHARS)
185
+ exit_code: int | None = None
186
+ duration_ms: int = Field(ge=0)
187
+ timed_out: bool = False
188
+
189
+ @field_validator("command")
190
+ @classmethod
191
+ def validate_command(cls, value: list[str]) -> list[str]:
192
+ normalized = _bounded_text_list(
193
+ value,
194
+ field_name="command",
195
+ max_chars=MAX_VALIDATION_COMMAND_PART_CHARS,
196
+ )
197
+ if sum(len(part) for part in normalized) > MAX_VALIDATION_COMMAND_CHARS:
198
+ raise ValueError(
199
+ "command must not exceed "
200
+ f"{MAX_VALIDATION_COMMAND_CHARS} total characters."
201
+ )
202
+ return normalized
203
+
204
+ @field_validator("cwd")
205
+ @classmethod
206
+ def cwd_must_not_be_blank(cls, value: str) -> str:
207
+ return _required_text(value, field_name="cwd")
208
+
209
+
210
+ class TesterResult(BoundedResultArgs):
211
+ __test__: ClassVar[bool] = False
212
+
213
+ status: TesterStatus
214
+ summary: str = Field(min_length=1, max_length=MAX_SUMMARY_CHARS)
215
+ executions: list[ValidationExecution] = Field(
216
+ default_factory=list,
217
+ max_length=MAX_VALIDATION_EXECUTIONS,
218
+ )
219
+ failure_summary: str | None = Field(default=None, max_length=MAX_DETAIL_CHARS)
220
+ blocked_reason: str | None = Field(default=None, max_length=MAX_DETAIL_CHARS)
221
+ uncertainties: list[str] = Field(default_factory=list, max_length=MAX_UNCERTAINTIES)
222
+
223
+ @field_validator("summary")
224
+ @classmethod
225
+ def summary_must_not_be_blank(cls, value: str) -> str:
226
+ return _required_text(value, field_name="summary")
227
+
228
+ @field_validator("failure_summary", "blocked_reason")
229
+ @classmethod
230
+ def normalize_optional_details(cls, value: str | None) -> str | None:
231
+ return _optional_text(value)
232
+
233
+ @field_validator("uncertainties")
234
+ @classmethod
235
+ def validate_uncertainties(cls, value: list[str]) -> list[str]:
236
+ return _bounded_text_list(
237
+ value,
238
+ field_name="uncertainties",
239
+ max_chars=MAX_DETAIL_CHARS,
240
+ )
241
+
242
+ @model_validator(mode="after")
243
+ def status_must_match_executions(self) -> Self:
244
+ failed_executions = [
245
+ item
246
+ for item in self.executions
247
+ if item.timed_out or item.exit_code is None or item.exit_code != 0
248
+ ]
249
+ if self.status == "passed":
250
+ if not self.executions or failed_executions:
251
+ raise ValueError(
252
+ "passed Tester results require at least one successful execution."
253
+ )
254
+ if self.failure_summary is not None or self.blocked_reason is not None:
255
+ raise ValueError(
256
+ "passed Tester results cannot include failure_summary or blocked_reason."
257
+ )
258
+ if self.status == "failed":
259
+ if not failed_executions:
260
+ raise ValueError(
261
+ "failed Tester results require a failed or timed-out execution."
262
+ )
263
+ if self.failure_summary is None:
264
+ raise ValueError("failed Tester results require failure_summary.")
265
+ if self.status == "blocked" and self.blocked_reason is None:
266
+ raise ValueError("blocked Tester results require blocked_reason.")
267
+ return self
268
+
269
+
270
+ ReviewSeverity = Literal["critical", "high", "medium", "low"]
271
+ ReviewRecommendation = Literal["approve", "changes_requested", "blocked"]
272
+
273
+
274
+ class ReviewFinding(ToolArgs):
275
+ severity: ReviewSeverity
276
+ path: str = Field(min_length=1, max_length=MAX_PATH_CHARS)
277
+ line: int | None = Field(default=None, ge=1)
278
+ problem: str = Field(min_length=1, max_length=MAX_DETAIL_CHARS)
279
+ evidence: str = Field(min_length=1, max_length=MAX_DETAIL_CHARS)
280
+ suggestion: str = Field(min_length=1, max_length=MAX_DETAIL_CHARS)
281
+
282
+ @field_validator("path", "problem", "evidence", "suggestion")
283
+ @classmethod
284
+ def required_fields_must_not_be_blank(cls, value: str, info) -> str:
285
+ return _required_text(value, field_name=info.field_name)
286
+
287
+
288
+ class ReviewerResult(BoundedResultArgs):
289
+ recommendation: ReviewRecommendation
290
+ summary: str = Field(min_length=1, max_length=MAX_SUMMARY_CHARS)
291
+ reviewed_scope: list[str] = Field(min_length=1, max_length=MAX_SCOPE_ITEMS)
292
+ findings: list[ReviewFinding] = Field(default_factory=list, max_length=MAX_FINDINGS)
293
+ uncertainties: list[str] = Field(default_factory=list, max_length=MAX_UNCERTAINTIES)
294
+ blocked_reason: str | None = Field(default=None, max_length=MAX_DETAIL_CHARS)
295
+
296
+ @field_validator("summary")
297
+ @classmethod
298
+ def summary_must_not_be_blank(cls, value: str) -> str:
299
+ return _required_text(value, field_name="summary")
300
+
301
+ @field_validator("reviewed_scope")
302
+ @classmethod
303
+ def validate_reviewed_scope(cls, value: list[str]) -> list[str]:
304
+ return _bounded_text_list(
305
+ value,
306
+ field_name="reviewed_scope",
307
+ max_chars=MAX_PATH_CHARS,
308
+ )
309
+
310
+ @field_validator("uncertainties")
311
+ @classmethod
312
+ def validate_uncertainties(cls, value: list[str]) -> list[str]:
313
+ return _bounded_text_list(
314
+ value,
315
+ field_name="uncertainties",
316
+ max_chars=MAX_DETAIL_CHARS,
317
+ )
318
+
319
+ @field_validator("blocked_reason")
320
+ @classmethod
321
+ def normalize_blocked_reason(cls, value: str | None) -> str | None:
322
+ return _optional_text(value)
323
+
324
+ @model_validator(mode="after")
325
+ def recommendation_must_match_findings(self) -> Self:
326
+ if self.recommendation == "changes_requested" and not self.findings:
327
+ raise ValueError("changes_requested requires at least one finding.")
328
+ if self.recommendation == "blocked" and self.blocked_reason is None:
329
+ raise ValueError("blocked Reviewer results require blocked_reason.")
330
+ if self.recommendation == "approve" and any(
331
+ finding.severity in {"critical", "high"}
332
+ for finding in self.findings
333
+ ):
334
+ raise ValueError(
335
+ "approve cannot include critical or high severity findings."
336
+ )
337
+ return self
338
+
339
+
340
+ SubAgentEnvelopeStatus = Literal["completed", "failed", "interrupted"]
341
+ SubAgentStopReason = Literal[
342
+ "submitted",
343
+ "max_turns",
344
+ "invalid_result",
345
+ "context_overflow",
346
+ "model_error",
347
+ "runtime_error",
348
+ "repeated_tool_call",
349
+ "interrupted",
350
+ ]
351
+ SubAgentPayload = ExplorerResult | TesterResult | ReviewerResult
352
+
353
+
354
+ class SubAgentResult(ToolArgs):
355
+ run_id: str = Field(min_length=1, max_length=100)
356
+ role: SubAgentRole
357
+ status: SubAgentEnvelopeStatus
358
+ stop_reason: SubAgentStopReason
359
+ summary: str = Field(min_length=1, max_length=MAX_SUMMARY_CHARS)
360
+ payload: SubAgentPayload | None = None
361
+ error: str | None = Field(default=None, max_length=MAX_DETAIL_CHARS)
362
+
363
+ @field_validator("run_id", "summary")
364
+ @classmethod
365
+ def required_fields_must_not_be_blank(cls, value: str, info) -> str:
366
+ return _required_text(value, field_name=info.field_name)
367
+
368
+ @field_validator("error")
369
+ @classmethod
370
+ def normalize_error(cls, value: str | None) -> str | None:
371
+ return _optional_text(value)
372
+
373
+ @model_validator(mode="after")
374
+ def envelope_must_match_status_and_role(self) -> Self:
375
+ if self.status == "completed":
376
+ if self.stop_reason != "submitted" or self.payload is None:
377
+ raise ValueError(
378
+ "completed SubAgent results require submitted payload."
379
+ )
380
+ elif self.payload is not None:
381
+ raise ValueError("failed or interrupted SubAgent results cannot include payload.")
382
+
383
+ expected_type = {
384
+ "explorer": ExplorerResult,
385
+ "tester": TesterResult,
386
+ "reviewer": ReviewerResult,
387
+ }[self.role]
388
+ if self.payload is not None and not isinstance(self.payload, expected_type):
389
+ raise ValueError(f"payload does not match SubAgent role: {self.role}")
390
+ return self
391
+
392
+
393
+ def _required_text(value: str, *, field_name: str) -> str:
394
+ normalized = value.strip()
395
+ if normalized == "":
396
+ raise ValueError(f"{field_name} must not be blank.")
397
+ return normalized
398
+
399
+
400
+ def _optional_text(value: str | None) -> str | None:
401
+ if value is None:
402
+ return None
403
+ normalized = value.strip()
404
+ return None if normalized == "" else normalized
405
+
406
+
407
+ def _bounded_text_list(
408
+ values: list[str],
409
+ *,
410
+ field_name: str,
411
+ max_chars: int,
412
+ ) -> list[str]:
413
+ normalized: list[str] = []
414
+ for value in values:
415
+ item = _required_text(value, field_name=field_name)
416
+ if len(item) > max_chars:
417
+ raise ValueError(
418
+ f"{field_name} items must not exceed {max_chars} characters."
419
+ )
420
+ normalized.append(item)
421
+ return normalized
@@ -0,0 +1,80 @@
1
+ from typing import Protocol
2
+
3
+ from mycode.subagents.contracts import SubAgentTask
4
+ from mycode.subagents.limits import MAX_DELEGATION_DEPTH
5
+ from mycode.subagents.observability import SubAgentObserver
6
+ from mycode.subagents.runtime import SubAgentExecution
7
+ from mycode.tools.base import PydanticTool, ToolResult
8
+
9
+
10
+ class SubAgentExecutor(Protocol):
11
+ def execute(
12
+ self,
13
+ task: SubAgentTask,
14
+ *,
15
+ observer: SubAgentObserver | None = None,
16
+ ) -> SubAgentExecution:
17
+ pass
18
+
19
+
20
+ class DelegateTaskTool(PydanticTool[SubAgentTask]):
21
+ name = "delegate_task"
22
+ description = (
23
+ "Delegate one bounded investigation, validation, or review task to an "
24
+ "independent SubAgent and return its structured result. Multiple independent "
25
+ "delegate_task calls in one response may run with bounded parallelism."
26
+ )
27
+ args_model = SubAgentTask
28
+ capability = "control"
29
+ risk = "low"
30
+
31
+ def __init__(
32
+ self,
33
+ runtime: SubAgentExecutor,
34
+ *,
35
+ current_depth: int = 0,
36
+ observer: SubAgentObserver | None = None,
37
+ ) -> None:
38
+ if current_depth < 0:
39
+ raise ValueError("current_depth must not be negative.")
40
+ self.runtime = runtime
41
+ self.current_depth = current_depth
42
+ self.observer = observer
43
+
44
+ def _run(self, args: SubAgentTask) -> ToolResult:
45
+ if self.current_depth >= MAX_DELEGATION_DEPTH:
46
+ return ToolResult.failure(
47
+ error=(
48
+ "SubAgent delegation depth limit reached: "
49
+ f"{MAX_DELEGATION_DEPTH}."
50
+ ),
51
+ metadata={
52
+ "tool_name": self.name,
53
+ "reason": "delegation_depth_limit",
54
+ "current_depth": self.current_depth,
55
+ "max_depth": MAX_DELEGATION_DEPTH,
56
+ },
57
+ )
58
+
59
+ execution = self.runtime.execute(args, observer=self.observer)
60
+ content = execution.result.model_dump_json(exclude_none=True)
61
+ metadata: dict[str, object] = {
62
+ "tool_name": self.name,
63
+ "run_id": execution.result.run_id,
64
+ "role": execution.result.role,
65
+ "child_status": execution.result.status,
66
+ "child_stop_reason": execution.result.stop_reason,
67
+ "conversation_message_count": execution.conversation_message_count,
68
+ "tool_call_count": execution.tool_call_count,
69
+ "validation_execution_count": execution.validation_execution_count,
70
+ "result_chars": len(content),
71
+ }
72
+ if execution.snapshot is not None:
73
+ metadata["snapshot_sha256"] = execution.snapshot.combined_sha256
74
+ if execution.token_usage is not None:
75
+ metadata["token_usage"] = {
76
+ "prompt_tokens": execution.token_usage.prompt_tokens,
77
+ "completion_tokens": execution.token_usage.completion_tokens,
78
+ "total_tokens": execution.token_usage.total_tokens,
79
+ }
80
+ return ToolResult.success(content=content, metadata=metadata)
@@ -0,0 +1,128 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ from mycode.agent.events import AgentToolCall
4
+ from mycode.agent.runner import (
5
+ ToolBatchExecution,
6
+ ToolCallExecution,
7
+ append_tool_call_limit_failures,
8
+ execute_tool_batch,
9
+ partition_tool_calls_by_limit,
10
+ )
11
+ from mycode.subagents.concurrency import BoundedDelegationScheduler
12
+ from mycode.subagents.limits import (
13
+ DEFAULT_MAX_CONCURRENT_DELEGATIONS,
14
+ DEFAULT_MAX_DELEGATIONS_PER_PARENT_RUN,
15
+ )
16
+ from mycode.tools.base import ToolResult
17
+ from mycode.tools.registry import ToolRegistry
18
+
19
+
20
+ @dataclass
21
+ class DelegationToolBatchHandler:
22
+ max_delegations_per_run: int = DEFAULT_MAX_DELEGATIONS_PER_PARENT_RUN
23
+ max_concurrent_delegations: int = DEFAULT_MAX_CONCURRENT_DELEGATIONS
24
+ delegation_count: int = field(default=0, init=False)
25
+
26
+ def __post_init__(self) -> None:
27
+ if self.max_delegations_per_run < 1:
28
+ raise ValueError("max_delegations_per_run must be at least 1.")
29
+ if self.max_concurrent_delegations < 1:
30
+ raise ValueError("max_concurrent_delegations must be at least 1.")
31
+
32
+ def start_run(self) -> None:
33
+ self.delegation_count = 0
34
+
35
+ def __call__(
36
+ self,
37
+ registry: ToolRegistry,
38
+ tool_calls: list[AgentToolCall],
39
+ ) -> ToolBatchExecution:
40
+ delegation_calls = [
41
+ tool_call for tool_call in tool_calls
42
+ if tool_call.name == "delegate_task"
43
+ ]
44
+ executable_calls, overflow_executions = partition_tool_calls_by_limit(
45
+ tool_calls
46
+ )
47
+ if not delegation_calls:
48
+ return execute_tool_batch(registry, tool_calls)
49
+ batch = self._execute_delegation_barrier(
50
+ registry,
51
+ executable_calls,
52
+ )
53
+ return append_tool_call_limit_failures(batch, overflow_executions)
54
+
55
+ def _execute_delegation_barrier(
56
+ self,
57
+ registry: ToolRegistry,
58
+ tool_calls: list[AgentToolCall],
59
+ ) -> ToolBatchExecution:
60
+ delegation_results: dict[int, ToolResult] = {}
61
+ executable_delegations: list[tuple[int, AgentToolCall]] = []
62
+ for index, tool_call in enumerate(tool_calls):
63
+ if tool_call.name != "delegate_task":
64
+ continue
65
+ self.delegation_count += 1
66
+ if self.delegation_count > self.max_delegations_per_run:
67
+ delegation_results[index] = _delegation_limit_result(
68
+ tool_call,
69
+ max_delegations_per_run=self.max_delegations_per_run,
70
+ )
71
+ else:
72
+ executable_delegations.append((index, tool_call))
73
+
74
+ scheduler = BoundedDelegationScheduler(
75
+ max_concurrent=self.max_concurrent_delegations
76
+ )
77
+ scheduled_calls = [
78
+ tool_call for _, tool_call in executable_delegations
79
+ ]
80
+ scheduled_results = scheduler.execute(
81
+ scheduled_calls,
82
+ lambda tool_call: registry.run_tool(
83
+ tool_call.name,
84
+ tool_call.arguments,
85
+ ),
86
+ )
87
+ for (index, _), result in zip(
88
+ executable_delegations,
89
+ scheduled_results,
90
+ strict=True,
91
+ ):
92
+ delegation_results[index] = result
93
+
94
+ executions: list[ToolCallExecution] = []
95
+ for index, tool_call in enumerate(tool_calls):
96
+ if tool_call.name == "delegate_task":
97
+ result = delegation_results[index]
98
+ else:
99
+ result = ToolResult.failure(
100
+ error=(
101
+ "Tool call skipped because delegate_task is a control-flow "
102
+ "barrier and its result must be considered first."
103
+ ),
104
+ metadata={
105
+ "tool_name": tool_call.name,
106
+ "reason": "skipped_due_to_delegation_barrier",
107
+ },
108
+ )
109
+ executions.append(ToolCallExecution(tool_call=tool_call, result=result))
110
+ return ToolBatchExecution(executions=tuple(executions))
111
+
112
+
113
+ def _delegation_limit_result(
114
+ tool_call: AgentToolCall,
115
+ *,
116
+ max_delegations_per_run: int,
117
+ ) -> ToolResult:
118
+ return ToolResult.failure(
119
+ error=(
120
+ "Delegation limit reached for this parent Agent run: "
121
+ f"{max_delegations_per_run}."
122
+ ),
123
+ metadata={
124
+ "tool_name": tool_call.name,
125
+ "reason": "delegation_limit",
126
+ "max_delegations_per_run": max_delegations_per_run,
127
+ },
128
+ )