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.
- mycode/__init__.py +0 -0
- mycode/adapters/__init__.py +21 -0
- mycode/adapters/jsonl.py +692 -0
- mycode/agent/__init__.py +25 -0
- mycode/agent/events.py +111 -0
- mycode/agent/outcome.py +103 -0
- mycode/agent/progress.py +373 -0
- mycode/agent/runner.py +1481 -0
- mycode/application/__init__.py +38 -0
- mycode/application/agent_session.py +367 -0
- mycode/application/events.py +59 -0
- mycode/application/runtime.py +211 -0
- mycode/application/sessions.py +180 -0
- mycode/cli.py +840 -0
- mycode/config.py +355 -0
- mycode/context/__init__.py +1 -0
- mycode/context/artifacts.py +672 -0
- mycode/context/budget.py +752 -0
- mycode/context/builder.py +112 -0
- mycode/context/compact.py +795 -0
- mycode/context/tool_result_format.py +199 -0
- mycode/context/tool_result_retention.py +261 -0
- mycode/conversation.py +78 -0
- mycode/error_handling.py +481 -0
- mycode/event_format.py +147 -0
- mycode/instructions.py +285 -0
- mycode/llm.py +771 -0
- mycode/mcp/__init__.py +41 -0
- mycode/mcp/client.py +44 -0
- mycode/mcp/config.py +207 -0
- mycode/mcp/errors.py +302 -0
- mycode/mcp/manager.py +339 -0
- mycode/mcp/models.py +20 -0
- mycode/mcp/result_adapter.py +58 -0
- mycode/mcp/tool_adapter.py +145 -0
- mycode/mcp/trust.py +313 -0
- mycode/memory.py +570 -0
- mycode/memory_context.py +245 -0
- mycode/messages.py +63 -0
- mycode/observability.py +28 -0
- mycode/permissions.py +262 -0
- mycode/persistence/__init__.py +1 -0
- mycode/persistence/filesystem.py +291 -0
- mycode/persistence/project_storage.py +208 -0
- mycode/persistence/session_lock.py +138 -0
- mycode/persistence/session_store.py +503 -0
- mycode/presentation/__init__.py +1 -0
- mycode/presentation/cli/__init__.py +14 -0
- mycode/presentation/cli/confirmer.py +116 -0
- mycode/presentation/cli/mcp_trust.py +61 -0
- mycode/presentation/cli/presenter.py +320 -0
- mycode/presentation/cli/session_menu.py +146 -0
- mycode/presentation/cli/subagent_observer.py +124 -0
- mycode/presentation/command_format.py +90 -0
- mycode/presentation/commands.py +95 -0
- mycode/presentation/tui/__init__.py +6 -0
- mycode/presentation/tui/app.py +1351 -0
- mycode/presentation/tui/interactions.py +253 -0
- mycode/presentation/tui/presenter.py +266 -0
- mycode/presentation/tui/screens.py +305 -0
- mycode/presentation/tui/widgets.py +214 -0
- mycode/project.py +22 -0
- mycode/prompts.py +181 -0
- mycode/reasoning.py +40 -0
- mycode/session.py +86 -0
- mycode/skills/__init__.py +27 -0
- mycode/skills/builtin/database-recovery/SKILL.md +138 -0
- mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
- mycode/skills/registry.py +295 -0
- mycode/skills/state.py +68 -0
- mycode/subagents/__init__.py +1 -0
- mycode/subagents/audit.py +212 -0
- mycode/subagents/concurrency.py +124 -0
- mycode/subagents/contracts.py +421 -0
- mycode/subagents/delegate.py +80 -0
- mycode/subagents/delegation.py +128 -0
- mycode/subagents/lifecycle.py +86 -0
- mycode/subagents/limits.py +7 -0
- mycode/subagents/observability.py +150 -0
- mycode/subagents/persistence.py +152 -0
- mycode/subagents/profiles.py +184 -0
- mycode/subagents/prompts.py +67 -0
- mycode/subagents/results.py +178 -0
- mycode/subagents/runtime.py +528 -0
- mycode/subagents/snapshots.py +211 -0
- mycode/subagents/tool_batch.py +260 -0
- mycode/tools/__init__.py +81 -0
- mycode/tools/base.py +222 -0
- mycode/tools/bounds.py +14 -0
- mycode/tools/command_executor.py +167 -0
- mycode/tools/command_output.py +166 -0
- mycode/tools/command_risk.py +596 -0
- mycode/tools/defaults.py +59 -0
- mycode/tools/edit_file.py +524 -0
- mycode/tools/file_mutation.py +30 -0
- mycode/tools/glob.py +247 -0
- mycode/tools/grep.py +324 -0
- mycode/tools/ignore.py +122 -0
- mycode/tools/inspect_changes.py +269 -0
- mycode/tools/load_skill.py +92 -0
- mycode/tools/memory.py +264 -0
- mycode/tools/path_permissions.py +78 -0
- mycode/tools/patterns.py +48 -0
- mycode/tools/permission_metadata.py +27 -0
- mycode/tools/process_tree.py +166 -0
- mycode/tools/read_file.py +242 -0
- mycode/tools/read_skill_resource.py +93 -0
- mycode/tools/registry.py +279 -0
- mycode/tools/run_command.py +237 -0
- mycode/tools/run_skill_script.py +206 -0
- mycode/tools/run_validation.py +107 -0
- mycode/tools/submit_result.py +93 -0
- mycode/tools/text.py +15 -0
- mycode/tools/validation_command.py +377 -0
- mycode/tools/workspace.py +33 -0
- mycode/tools/write_file.py +169 -0
- mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
- mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
- mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|