@openlucaskaka/kagent 0.1.7

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 (117) hide show
  1. package/README.md +353 -0
  2. package/npm/bin/kagent-serve.js +6 -0
  3. package/npm/bin/kagent.js +6 -0
  4. package/npm/lib/App.js +524 -0
  5. package/npm/lib/app-state.js +224 -0
  6. package/npm/lib/approval-choice.js +25 -0
  7. package/npm/lib/commands.js +59 -0
  8. package/npm/lib/editor.js +188 -0
  9. package/npm/lib/ink-runner.js +41 -0
  10. package/npm/lib/kagent-home.js +39 -0
  11. package/npm/lib/launcher.js +221 -0
  12. package/npm/lib/protocol.js +33 -0
  13. package/npm/lib/provider-setup.js +139 -0
  14. package/npm/lib/python-runner.js +892 -0
  15. package/npm/lib/runtime-client.js +390 -0
  16. package/npm/lib/terminal-input.js +127 -0
  17. package/npm/lib/terminal-text.js +19 -0
  18. package/npm/lib/terminal-width.js +14 -0
  19. package/npm/lib/transcript.js +227 -0
  20. package/npm/lib/ui-components.js +247 -0
  21. package/npm/lib/update-manager.js +334 -0
  22. package/package.json +39 -0
  23. package/pyproject.toml +55 -0
  24. package/src/kagent/__init__.py +90 -0
  25. package/src/kagent/cli/__init__.py +5 -0
  26. package/src/kagent/cli/__main__.py +6 -0
  27. package/src/kagent/cli/commands.py +112 -0
  28. package/src/kagent/cli/conversation.py +127 -0
  29. package/src/kagent/cli/interactive.py +841 -0
  30. package/src/kagent/cli/main.py +685 -0
  31. package/src/kagent/cli/memory.py +460 -0
  32. package/src/kagent/cli/pending_approval.py +169 -0
  33. package/src/kagent/cli/provider.py +27 -0
  34. package/src/kagent/cli/session_commands.py +401 -0
  35. package/src/kagent/cli/stdio_runtime.py +784 -0
  36. package/src/kagent/cli/trace.py +67 -0
  37. package/src/kagent/cli/ui.py +931 -0
  38. package/src/kagent/core/__init__.py +0 -0
  39. package/src/kagent/core/agent.py +296 -0
  40. package/src/kagent/core/faults.py +11 -0
  41. package/src/kagent/core/invariants.py +47 -0
  42. package/src/kagent/core/normalization.py +81 -0
  43. package/src/kagent/core/planning.py +48 -0
  44. package/src/kagent/core/state.py +73 -0
  45. package/src/kagent/core/summary.py +64 -0
  46. package/src/kagent/core/tools.py +196 -0
  47. package/src/kagent/core/trace.py +57 -0
  48. package/src/kagent/eval/__init__.py +11 -0
  49. package/src/kagent/eval/cases.py +229 -0
  50. package/src/kagent/eval/evaluator.py +216 -0
  51. package/src/kagent/integrations/__init__.py +3 -0
  52. package/src/kagent/integrations/audit.py +95 -0
  53. package/src/kagent/integrations/backends.py +131 -0
  54. package/src/kagent/integrations/memory.py +301 -0
  55. package/src/kagent/ops/__init__.py +0 -0
  56. package/src/kagent/ops/batch.py +156 -0
  57. package/src/kagent/ops/doctor.py +255 -0
  58. package/src/kagent/ops/metrics.py +214 -0
  59. package/src/kagent/ops/release_evidence.py +877 -0
  60. package/src/kagent/ops/release_manifest.py +142 -0
  61. package/src/kagent/ops/trace_replay.py +285 -0
  62. package/src/kagent/providers/__init__.py +7 -0
  63. package/src/kagent/providers/embeddings.py +187 -0
  64. package/src/kagent/providers/llm.py +770 -0
  65. package/src/kagent/py.typed +1 -0
  66. package/src/kagent/runtime/__init__.py +28 -0
  67. package/src/kagent/runtime/action_graph.py +543 -0
  68. package/src/kagent/runtime/agent.py +2089 -0
  69. package/src/kagent/runtime/approval.py +64 -0
  70. package/src/kagent/runtime/cancellation.py +64 -0
  71. package/src/kagent/runtime/checkpoint_state.py +146 -0
  72. package/src/kagent/runtime/checkpoint_storage.py +270 -0
  73. package/src/kagent/runtime/context.py +65 -0
  74. package/src/kagent/runtime/file_transaction.py +195 -0
  75. package/src/kagent/runtime/hooks.py +74 -0
  76. package/src/kagent/runtime/metadata.py +116 -0
  77. package/src/kagent/runtime/patch_checkpoints.py +385 -0
  78. package/src/kagent/runtime/policy.py +50 -0
  79. package/src/kagent/runtime/presentation.py +205 -0
  80. package/src/kagent/runtime/redaction.py +130 -0
  81. package/src/kagent/runtime/sandbox.py +331 -0
  82. package/src/kagent/runtime/skills.py +66 -0
  83. package/src/kagent/runtime/steering.py +56 -0
  84. package/src/kagent/runtime/steps.py +255 -0
  85. package/src/kagent/runtime/task_state.py +40 -0
  86. package/src/kagent/runtime/tools.py +3532 -0
  87. package/src/kagent/runtime/types.py +240 -0
  88. package/src/kagent/runtime/workspace.py +597 -0
  89. package/src/kagent/service/__init__.py +26 -0
  90. package/src/kagent/service/__main__.py +6 -0
  91. package/src/kagent/service/active_runs.py +178 -0
  92. package/src/kagent/service/cli.py +737 -0
  93. package/src/kagent/service/contract.py +2571 -0
  94. package/src/kagent/service/errors.py +71 -0
  95. package/src/kagent/service/idempotency.py +584 -0
  96. package/src/kagent/service/router.py +884 -0
  97. package/src/kagent/service/run.py +150 -0
  98. package/src/kagent/service/runtime.py +1915 -0
  99. package/src/kagent/service/runtime_approval.py +20 -0
  100. package/src/kagent/service/runtime_cancel.py +192 -0
  101. package/src/kagent/service/runtime_lifecycle.py +229 -0
  102. package/src/kagent/service/runtime_metadata.py +21 -0
  103. package/src/kagent/service/runtime_policy.py +115 -0
  104. package/src/kagent/service/runtime_recovery.py +573 -0
  105. package/src/kagent/service/runtime_resume.py +382 -0
  106. package/src/kagent/service/runtime_resume_claim.py +116 -0
  107. package/src/kagent/service/runtime_run.py +350 -0
  108. package/src/kagent/service/runtime_status.py +2007 -0
  109. package/src/kagent/service/safety.py +139 -0
  110. package/src/kagent/service/server.py +114 -0
  111. package/src/kagent/service/status.py +233 -0
  112. package/src/kagent/service/trace_store.py +322 -0
  113. package/src/kagent/service/transport.py +24 -0
  114. package/src/kagent/utils/__init__.py +0 -0
  115. package/src/kagent/utils/config_validation.py +21 -0
  116. package/src/kagent/utils/json_output.py +23 -0
  117. package/src/kagent/utils/paths.py +473 -0
@@ -0,0 +1,3532 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import ipaddress
5
+ import os
6
+ import re
7
+ import socket
8
+ import subprocess
9
+ import time
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any, Callable, Dict
17
+
18
+ from kagent.integrations.memory import MilvusLongTermMemory, RedisShortTermMemory
19
+ from kagent.providers.embeddings import (
20
+ EmbeddingProviderConfig,
21
+ OpenAICompatibleEmbeddingProvider,
22
+ )
23
+ from kagent.runtime.file_transaction import capture_text_states, workspace_transaction
24
+ from kagent.runtime.patch_checkpoints import (
25
+ PatchCheckpointStore,
26
+ commit_checkpointed_text_changes,
27
+ )
28
+ from kagent.runtime.policy import RuntimePolicy
29
+ from kagent.runtime.redaction import redact_runtime_payload
30
+ from kagent.runtime.sandbox import run_shell_sandboxed
31
+ from kagent.runtime.skills import RuntimeSkillRegistry
32
+ from kagent.runtime.task_state import TASK_EVENTS, TASK_STATES, TaskStateMachine
33
+ from kagent.runtime.types import AgentObservation
34
+ from kagent.runtime.workspace import VIRTUAL_WORKSPACE_KINDS, RuntimeWorkspace
35
+
36
+ RuntimeToolHandler = Callable[[Dict[str, Any]], Dict[str, Any]]
37
+ _ARTIFACT_KINDS = ("report", "plan", "decision", "data", "message")
38
+ _ARTIFACT_FORMATS = ("markdown", "plain_text", "json")
39
+ _TASK_STATUSES = ("pending", "in_progress", "blocked", "done", "failed")
40
+ _TASK_PRIORITIES = ("low", "normal", "high")
41
+ _SHORT_TEXT_MAX_LENGTH = 200
42
+ _TASK_TITLE_MAX_LENGTH = 500
43
+ _LONG_TEXT_MAX_LENGTH = 20000
44
+ _TAGS_MAX_ITEMS = 20
45
+ _DECISION_CRITERIA_MAX_ITEMS = 20
46
+ _DECISION_OPTIONS_MAX_ITEMS = 50
47
+ _RUBRIC_CRITERIA_MAX_ITEMS = 100
48
+ _TASK_ITEMS_MAX_ITEMS = 200
49
+ _RUBRIC_SEVERITIES = ("low", "normal", "blocking")
50
+ _DELEGATE_GOAL_MAX_LENGTH = 4000
51
+ _DELEGATE_MAX_ITERATIONS = 3
52
+ _HTTP_REQUEST_MAX_BYTES = 65536
53
+ _HTTP_REQUEST_TIMEOUT_SECONDS = 10.0
54
+ _BLOCKED_HTTP_HOSTS = {"localhost", "localhost."}
55
+ _HTTP_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
56
+ _URL_SECRET_KEY_PARTS = (
57
+ "api_key",
58
+ "apikey",
59
+ "authorization",
60
+ "bearer",
61
+ "password",
62
+ "secret",
63
+ "token",
64
+ )
65
+ _URL_SECRET_VALUE_PATTERNS = (
66
+ re.compile(r"sk-[A-Za-z0-9:_-]{6,}"),
67
+ re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._:/+=-]{6,}"),
68
+ )
69
+ _APPLY_PATCH_MAX_BYTES = 20000
70
+ _READ_FILE_MAX_BYTES = 65536
71
+ _LIST_FILES_MAX_DEPTH = 5
72
+ _LIST_FILES_MAX_ENTRIES = 500
73
+ _SHELL_COMMAND_MAX_LENGTH = 2000
74
+ _SHELL_COMMAND_MAX_OUTPUT_BYTES = 32768
75
+ _SHELL_COMMAND_DEFAULT_TIMEOUT_SECONDS = 10.0
76
+ _SHELL_COMMAND_MAX_TIMEOUT_SECONDS = 30.0
77
+ _SHELL_BACKGROUND_PATTERN = re.compile(r"(?<!&)&(?!&)")
78
+ _SHELL_INTERACTIVE_PATTERNS = (
79
+ re.compile(r"\bpython[0-9.]*\s+-i\b"),
80
+ re.compile(r"\b(ipython|node|ruby|irb)\b\s*$"),
81
+ re.compile(r"\b(read|vim|vi|nano|less|more|top|tail\s+-f)\b"),
82
+ )
83
+ _SHELL_DESTRUCTIVE_PATTERNS = (
84
+ re.compile(r"\brm\b(?=[^;&|]*-[A-Za-z]*r)(?=[^;&|]*-[A-Za-z]*f)"),
85
+ re.compile(r"\bsudo\b"),
86
+ re.compile(r"\b(?:chmod|chown|chgrp)\b(?=[^;&|]*\s-R\b)"),
87
+ re.compile(r"\bdd\s+if="),
88
+ re.compile(r"\b(?:mkfs|diskutil|shutdown|reboot|halt)\b"),
89
+ )
90
+ _SHELL_SECRET_EXPOSURE_PATTERNS = (
91
+ re.compile(r"^\s*(?:env|printenv|set|export)\b(?:\s|$)"),
92
+ re.compile(r"\b(?:cat|grep|awk|sed)\b[^;&|]*(?:^|[\s/\\])\.env(?:[\s.]|$)"),
93
+ re.compile(r"\b(?:cat|grep|awk|sed)\b[^;&|]*(?:secret|token|api[_-]?key)", re.I),
94
+ )
95
+ _SHELL_NETWORK_COMMAND_PATTERNS = (
96
+ re.compile(r"\b(?:curl|wget|ssh|scp|sftp|rsync|nc|netcat|telnet)\b"),
97
+ )
98
+ _SHELL_INLINE_NETWORK_CODE_PATTERNS = (
99
+ re.compile(
100
+ r"\bpython[0-9.]*\b[^;&|]*\s+-c\s+.+"
101
+ r"\b(?:socket|urllib|http\.client|requests|httpx|aiohttp)\b",
102
+ re.I,
103
+ ),
104
+ re.compile(
105
+ r"\bnode\b[^;&|]*\s+-e\s+.+"
106
+ r"(?:\bfetch\s*\(|require\s*\(\s*['\"](?:net|http|https|dgram)['\"]\s*\))",
107
+ re.I,
108
+ ),
109
+ )
110
+ _SHELL_PIPE_TO_SHELL_PATTERN = re.compile(
111
+ r"\|\s*(?:sh|bash|zsh|python[0-9.]*|node|ruby)\b"
112
+ )
113
+ _RUNTIME_WORKSPACE_DIR_ENV_VARS = (
114
+ "KAGENT_RUNTIME_WORKSPACE_DIR",
115
+ "KAGENT_SERVICE_RUNTIME_WORKSPACE_DIR",
116
+ )
117
+ _RUNTIME_SKILLS_DIR_ENV_VARS = (
118
+ "KAGENT_RUNTIME_SKILLS_DIR",
119
+ "KAGENT_SKILLS_DIR",
120
+ )
121
+ _REDIS_URL_ENV_VARS = ("KAGENT_REDIS_URL",)
122
+ _MILVUS_URL_ENV_VARS = ("KAGENT_MILVUS_URL",)
123
+ _EMBEDDING_BASE_URL_ENV_VARS = ("KAGENT_EMBEDDING_BASE_URL", "KAGENT_LLM_BASE_URL")
124
+ _EMBEDDING_API_KEY_ENV_VARS = ("KAGENT_EMBEDDING_API_KEY", "KAGENT_LLM_API_KEY")
125
+ _EMBEDDING_MODEL_ENV_VARS = ("KAGENT_EMBEDDING_MODEL",)
126
+ _EMBEDDING_MAX_RETRIES_ENV_VAR = "KAGENT_EMBEDDING_MAX_RETRIES"
127
+ _EMBEDDING_RETRY_BACKOFF_ENV_VAR = "KAGENT_EMBEDDING_RETRY_BACKOFF_SECONDS"
128
+ _EXTERNAL_BACKEND_TIMEOUT_ENV_VAR = "KAGENT_EXTERNAL_BACKEND_TIMEOUT_SECONDS"
129
+ _APP_NAME_MAX_LENGTH = 120
130
+ _APP_NAME_ALLOWED_PATTERN = re.compile(r"^[\w .+()#&-]+$", re.UNICODE)
131
+ _OPEN_COMMAND_TIMEOUT_SECONDS = 10.0
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class _PatchOperation:
136
+ operation: str
137
+ relative_path: str
138
+ destination_path: str = ""
139
+ content: str = ""
140
+ lines: tuple[str, ...] = ()
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class RuntimeToolSpec:
145
+ name: str
146
+ description: str
147
+ handler: RuntimeToolHandler
148
+ input_schema: Dict[str, Any] = field(default_factory=lambda: {"type": "object"})
149
+ output_schema: Dict[str, Any] = field(default_factory=lambda: {"type": "object"})
150
+ timeout_seconds: float = 30.0
151
+
152
+ def __post_init__(self) -> None:
153
+ if self.timeout_seconds <= 0:
154
+ raise ValueError("timeout_seconds must be positive")
155
+
156
+
157
+ _TEXT_OUTPUT_SCHEMA = {
158
+ "type": "object",
159
+ "required": ["text"],
160
+ "properties": {"text": {"type": "string"}},
161
+ "additionalProperties": False,
162
+ }
163
+
164
+ _ARTIFACT_OUTPUT_SCHEMA = {
165
+ "type": "object",
166
+ "required": [
167
+ "artifact_id",
168
+ "title",
169
+ "kind",
170
+ "format",
171
+ "content",
172
+ "tags",
173
+ "bytes",
174
+ ],
175
+ "properties": {
176
+ "artifact_id": {"type": "string"},
177
+ "title": {"type": "string"},
178
+ "kind": {"type": "string", "enum": list(_ARTIFACT_KINDS)},
179
+ "format": {"type": "string", "enum": list(_ARTIFACT_FORMATS)},
180
+ "content": {"type": "string"},
181
+ "tags": {"type": "array", "items": {"type": "string"}},
182
+ "bytes": {"type": "number", "minimum": 0},
183
+ },
184
+ "additionalProperties": False,
185
+ }
186
+
187
+ _APPLY_PATCH_CHANGED_FILE_OUTPUT_SCHEMA = {
188
+ "type": "object",
189
+ "required": ["path", "operation", "bytes", "sha256"],
190
+ "properties": {
191
+ "path": {"type": "string"},
192
+ "previous_path": {"type": "string"},
193
+ "operation": {"type": "string", "enum": ["add", "update", "delete", "move"]},
194
+ "bytes": {"type": "number", "minimum": 0},
195
+ "sha256": {"type": "string"},
196
+ },
197
+ "additionalProperties": False,
198
+ }
199
+
200
+ _APPLY_PATCH_OUTPUT_SCHEMA = {
201
+ "type": "object",
202
+ "required": ["changed_files", "file_count"],
203
+ "properties": {
204
+ "changed_files": {
205
+ "type": "array",
206
+ "items": _APPLY_PATCH_CHANGED_FILE_OUTPUT_SCHEMA,
207
+ },
208
+ "file_count": {"type": "number", "minimum": 0},
209
+ },
210
+ "additionalProperties": False,
211
+ }
212
+
213
+ _PATCH_HISTORY_OUTPUT_SCHEMA = {
214
+ "type": "object",
215
+ "required": ["checkpoints", "checkpoint_count"],
216
+ "properties": {
217
+ "checkpoints": {
218
+ "type": "array",
219
+ "items": {
220
+ "type": "object",
221
+ "required": ["checkpoint_id", "created_at", "file_count", "paths"],
222
+ "properties": {
223
+ "checkpoint_id": {"type": "string"},
224
+ "created_at": {"type": "string"},
225
+ "file_count": {"type": "number", "minimum": 0},
226
+ "paths": {"type": "array", "items": {"type": "string"}},
227
+ },
228
+ "additionalProperties": False,
229
+ },
230
+ },
231
+ "checkpoint_count": {"type": "number", "minimum": 0},
232
+ },
233
+ "additionalProperties": False,
234
+ }
235
+
236
+ _REVERT_PATCH_OUTPUT_SCHEMA = {
237
+ "type": "object",
238
+ "required": [
239
+ "checkpoint_id",
240
+ "reverted_checkpoint_id",
241
+ "changed_files",
242
+ "file_count",
243
+ ],
244
+ "properties": {
245
+ "checkpoint_id": {"type": "string"},
246
+ "reverted_checkpoint_id": {"type": "string"},
247
+ "changed_files": {
248
+ "type": "array",
249
+ "items": _APPLY_PATCH_CHANGED_FILE_OUTPUT_SCHEMA,
250
+ },
251
+ "file_count": {"type": "number", "minimum": 0},
252
+ },
253
+ "additionalProperties": False,
254
+ }
255
+
256
+ _DECISION_CRITERION_OUTPUT_SCHEMA = {
257
+ "type": "object",
258
+ "required": ["name", "weight"],
259
+ "properties": {
260
+ "name": {"type": "string"},
261
+ "weight": {"type": "number", "minimum": 0},
262
+ },
263
+ "additionalProperties": False,
264
+ }
265
+
266
+ _DECISION_RANKING_OUTPUT_SCHEMA = {
267
+ "type": "object",
268
+ "required": ["rank", "name", "score", "scores", "rationale"],
269
+ "properties": {
270
+ "rank": {"type": "number", "minimum": 1},
271
+ "name": {"type": "string"},
272
+ "score": {"type": "number"},
273
+ "scores": {"type": "array", "items": {"type": "number"}},
274
+ "rationale": {"type": "string"},
275
+ },
276
+ "additionalProperties": False,
277
+ }
278
+
279
+ _DECISION_MATRIX_OUTPUT_SCHEMA = {
280
+ "type": "object",
281
+ "required": ["question", "criteria", "rankings", "winner"],
282
+ "properties": {
283
+ "question": {"type": "string"},
284
+ "criteria": {
285
+ "type": "array",
286
+ "items": _DECISION_CRITERION_OUTPUT_SCHEMA,
287
+ },
288
+ "rankings": {
289
+ "type": "array",
290
+ "items": _DECISION_RANKING_OUTPUT_SCHEMA,
291
+ },
292
+ "winner": {"type": "string"},
293
+ },
294
+ "additionalProperties": False,
295
+ }
296
+
297
+ _RUBRIC_CRITERION_OUTPUT_SCHEMA = {
298
+ "type": "object",
299
+ "required": ["name", "passed", "severity", "evidence"],
300
+ "properties": {
301
+ "name": {"type": "string"},
302
+ "passed": {"type": "boolean"},
303
+ "severity": {"type": "string", "enum": list(_RUBRIC_SEVERITIES)},
304
+ "evidence": {"type": "string"},
305
+ },
306
+ "additionalProperties": False,
307
+ }
308
+
309
+ _RUBRIC_SCORE_OUTPUT_SCHEMA = {
310
+ "type": "object",
311
+ "required": [
312
+ "criteria",
313
+ "passed",
314
+ "failed",
315
+ "total",
316
+ "score_percent",
317
+ "blocking_failures",
318
+ "failed_criteria",
319
+ ],
320
+ "properties": {
321
+ "criteria": {
322
+ "type": "array",
323
+ "items": _RUBRIC_CRITERION_OUTPUT_SCHEMA,
324
+ },
325
+ "passed": {"type": "number", "minimum": 0},
326
+ "failed": {"type": "number", "minimum": 0},
327
+ "total": {"type": "number", "minimum": 0},
328
+ "score_percent": {"type": "number", "minimum": 0, "maximum": 100},
329
+ "blocking_failures": {"type": "array", "items": {"type": "string"}},
330
+ "failed_criteria": {"type": "array", "items": {"type": "string"}},
331
+ },
332
+ "additionalProperties": False,
333
+ }
334
+
335
+ _TASK_ITEM_OUTPUT_SCHEMA = {
336
+ "type": "object",
337
+ "required": ["title", "status", "priority"],
338
+ "properties": {
339
+ "title": {"type": "string"},
340
+ "status": {"type": "string", "enum": list(_TASK_STATUSES)},
341
+ "priority": {"type": "string", "enum": list(_TASK_PRIORITIES)},
342
+ "owner": {"type": "string"},
343
+ "due": {"type": "string"},
344
+ },
345
+ "additionalProperties": False,
346
+ }
347
+
348
+ _TASK_STATUS_COUNTS_OUTPUT_SCHEMA = {
349
+ "type": "object",
350
+ "required": list(_TASK_STATUSES),
351
+ "properties": {
352
+ "pending": {"type": "number", "minimum": 0},
353
+ "in_progress": {"type": "number", "minimum": 0},
354
+ "blocked": {"type": "number", "minimum": 0},
355
+ "done": {"type": "number", "minimum": 0},
356
+ "failed": {"type": "number", "minimum": 0},
357
+ },
358
+ "additionalProperties": False,
359
+ }
360
+
361
+ _TASK_LIST_OUTPUT_SCHEMA = {
362
+ "type": "object",
363
+ "required": ["items", "counts", "total"],
364
+ "properties": {
365
+ "items": {"type": "array", "items": _TASK_ITEM_OUTPUT_SCHEMA},
366
+ "counts": _TASK_STATUS_COUNTS_OUTPUT_SCHEMA,
367
+ "total": {"type": "number", "minimum": 0},
368
+ },
369
+ "additionalProperties": False,
370
+ }
371
+
372
+ _TASK_TRANSITION_OUTPUT_SCHEMA = {
373
+ "type": "object",
374
+ "required": ["previous_state", "event", "state"],
375
+ "properties": {
376
+ "previous_state": {"type": "string", "enum": list(TASK_STATES)},
377
+ "event": {"type": "string", "enum": list(TASK_EVENTS)},
378
+ "state": {"type": "string", "enum": list(TASK_STATES)},
379
+ },
380
+ "additionalProperties": False,
381
+ }
382
+
383
+ _HTTP_REQUEST_OUTPUT_SCHEMA = {
384
+ "type": "object",
385
+ "required": [
386
+ "url",
387
+ "status_code",
388
+ "content_type",
389
+ "body_text",
390
+ "bytes",
391
+ "truncated",
392
+ ],
393
+ "properties": {
394
+ "url": {"type": "string"},
395
+ "status_code": {"type": "number", "minimum": 100, "maximum": 599},
396
+ "content_type": {"type": "string"},
397
+ "body_text": {"type": "string"},
398
+ "bytes": {"type": "number", "minimum": 0},
399
+ "truncated": {"type": "boolean"},
400
+ },
401
+ "additionalProperties": False,
402
+ }
403
+
404
+ _OPEN_URL_OUTPUT_SCHEMA = {
405
+ "type": "object",
406
+ "required": ["url", "opened", "application", "command"],
407
+ "properties": {
408
+ "url": {"type": "string"},
409
+ "opened": {"type": "boolean"},
410
+ "application": {"type": "string"},
411
+ "command": {"type": "string"},
412
+ },
413
+ "additionalProperties": False,
414
+ }
415
+
416
+ _OPEN_APP_OUTPUT_SCHEMA = {
417
+ "type": "object",
418
+ "required": ["application", "opened", "command"],
419
+ "properties": {
420
+ "application": {"type": "string"},
421
+ "opened": {"type": "boolean"},
422
+ "command": {"type": "string"},
423
+ },
424
+ "additionalProperties": False,
425
+ }
426
+
427
+ _READ_FILE_OUTPUT_SCHEMA = {
428
+ "type": "object",
429
+ "required": ["path", "content", "bytes", "truncated", "sha256"],
430
+ "properties": {
431
+ "path": {"type": "string"},
432
+ "content": {"type": "string"},
433
+ "bytes": {"type": "number", "minimum": 0},
434
+ "truncated": {"type": "boolean"},
435
+ "sha256": {"type": "string"},
436
+ },
437
+ "additionalProperties": False,
438
+ }
439
+
440
+ _LIST_FILES_ENTRY_OUTPUT_SCHEMA = {
441
+ "type": "object",
442
+ "required": ["path", "type", "bytes"],
443
+ "properties": {
444
+ "path": {"type": "string"},
445
+ "type": {"type": "string", "enum": ["directory", "file"]},
446
+ "bytes": {"type": "number", "minimum": 0},
447
+ },
448
+ "additionalProperties": False,
449
+ }
450
+
451
+ _LIST_FILES_OUTPUT_SCHEMA = {
452
+ "type": "object",
453
+ "required": ["root", "entries", "file_count", "truncated"],
454
+ "properties": {
455
+ "root": {"type": "string"},
456
+ "entries": {"type": "array", "items": _LIST_FILES_ENTRY_OUTPUT_SCHEMA},
457
+ "file_count": {"type": "number", "minimum": 0},
458
+ "truncated": {"type": "boolean"},
459
+ },
460
+ "additionalProperties": False,
461
+ }
462
+
463
+ _WORKSPACE_ASSET_METADATA_SCHEMA = {
464
+ "type": "object",
465
+ "required": [
466
+ "kind",
467
+ "path",
468
+ "bytes",
469
+ "sha256",
470
+ "created_at",
471
+ "updated_at",
472
+ "metadata",
473
+ ],
474
+ "properties": {
475
+ "kind": {"type": "string", "enum": list(VIRTUAL_WORKSPACE_KINDS)},
476
+ "path": {"type": "string"},
477
+ "bytes": {"type": "number", "minimum": 0},
478
+ "sha256": {"type": "string"},
479
+ "created_at": {"type": "string"},
480
+ "updated_at": {"type": "string"},
481
+ "metadata": {"type": "object"},
482
+ },
483
+ "additionalProperties": False,
484
+ }
485
+
486
+ _WORKSPACE_READ_OUTPUT_SCHEMA = {
487
+ "type": "object",
488
+ "required": [
489
+ "kind",
490
+ "path",
491
+ "bytes",
492
+ "sha256",
493
+ "created_at",
494
+ "updated_at",
495
+ "metadata",
496
+ "content",
497
+ "truncated",
498
+ ],
499
+ "properties": {
500
+ **_WORKSPACE_ASSET_METADATA_SCHEMA["properties"],
501
+ "content": {"type": "string"},
502
+ "truncated": {"type": "boolean"},
503
+ },
504
+ "additionalProperties": False,
505
+ }
506
+
507
+ _WORKSPACE_LIST_ENTRY_OUTPUT_SCHEMA = {
508
+ "type": "object",
509
+ "required": ["path", "type", "bytes", "sha256"],
510
+ "properties": {
511
+ "path": {"type": "string"},
512
+ "type": {"type": "string", "enum": ["directory", "file"]},
513
+ "bytes": {"type": "number", "minimum": 0},
514
+ "sha256": {"type": "string"},
515
+ },
516
+ "additionalProperties": False,
517
+ }
518
+
519
+ _WORKSPACE_LIST_OUTPUT_SCHEMA = {
520
+ "type": "object",
521
+ "required": ["kind", "root", "entries", "file_count", "truncated"],
522
+ "properties": {
523
+ "kind": {"type": "string", "enum": list(VIRTUAL_WORKSPACE_KINDS)},
524
+ "root": {"type": "string"},
525
+ "entries": {
526
+ "type": "array",
527
+ "items": _WORKSPACE_LIST_ENTRY_OUTPUT_SCHEMA,
528
+ },
529
+ "file_count": {"type": "number", "minimum": 0},
530
+ "truncated": {"type": "boolean"},
531
+ },
532
+ "additionalProperties": False,
533
+ }
534
+
535
+ _WORKSPACE_SEARCH_MATCH_OUTPUT_SCHEMA = {
536
+ "type": "object",
537
+ "required": ["path", "line_number", "line", "byte_offset", "sha256"],
538
+ "properties": {
539
+ "path": {"type": "string"},
540
+ "line_number": {"type": "number", "minimum": 1},
541
+ "line": {"type": "string"},
542
+ "byte_offset": {"type": "number", "minimum": 0},
543
+ "sha256": {"type": "string"},
544
+ },
545
+ "additionalProperties": False,
546
+ }
547
+
548
+ _WORKSPACE_SEARCH_OUTPUT_SCHEMA = {
549
+ "type": "object",
550
+ "required": ["kind", "root", "query", "matches", "match_count", "truncated"],
551
+ "properties": {
552
+ "kind": {"type": "string", "enum": list(VIRTUAL_WORKSPACE_KINDS)},
553
+ "root": {"type": "string"},
554
+ "query": {"type": "string"},
555
+ "matches": {
556
+ "type": "array",
557
+ "items": _WORKSPACE_SEARCH_MATCH_OUTPUT_SCHEMA,
558
+ },
559
+ "match_count": {"type": "number", "minimum": 0},
560
+ "truncated": {"type": "boolean"},
561
+ },
562
+ "additionalProperties": False,
563
+ }
564
+
565
+ _WORKSPACE_HISTORY_REVISION_OUTPUT_SCHEMA = {
566
+ "type": "object",
567
+ "required": [
568
+ "revision_id",
569
+ "bytes",
570
+ "sha256",
571
+ "created_at",
572
+ "content",
573
+ "content_truncated",
574
+ ],
575
+ "properties": {
576
+ "revision_id": {"type": "string"},
577
+ "bytes": {"type": "number", "minimum": 0},
578
+ "sha256": {"type": "string"},
579
+ "created_at": {"type": "string"},
580
+ "content": {"type": "string"},
581
+ "content_truncated": {"type": "boolean"},
582
+ },
583
+ "additionalProperties": False,
584
+ }
585
+
586
+ _WORKSPACE_HISTORY_OUTPUT_SCHEMA = {
587
+ "type": "object",
588
+ "required": ["kind", "path", "revisions", "revision_count", "truncated"],
589
+ "properties": {
590
+ "kind": {"type": "string", "enum": list(VIRTUAL_WORKSPACE_KINDS)},
591
+ "path": {"type": "string"},
592
+ "revisions": {
593
+ "type": "array",
594
+ "items": _WORKSPACE_HISTORY_REVISION_OUTPUT_SCHEMA,
595
+ },
596
+ "revision_count": {"type": "number", "minimum": 0},
597
+ "truncated": {"type": "boolean"},
598
+ },
599
+ "additionalProperties": False,
600
+ }
601
+
602
+ _WORKSPACE_DIFF_OUTPUT_SCHEMA = {
603
+ "type": "object",
604
+ "required": [
605
+ "kind",
606
+ "path",
607
+ "revision_id",
608
+ "from_sha256",
609
+ "to_sha256",
610
+ "diff",
611
+ "bytes",
612
+ "truncated",
613
+ ],
614
+ "properties": {
615
+ "kind": {"type": "string", "enum": list(VIRTUAL_WORKSPACE_KINDS)},
616
+ "path": {"type": "string"},
617
+ "revision_id": {"type": "string"},
618
+ "from_sha256": {"type": "string"},
619
+ "to_sha256": {"type": "string"},
620
+ "diff": {"type": "string"},
621
+ "bytes": {"type": "number", "minimum": 0},
622
+ "truncated": {"type": "boolean"},
623
+ },
624
+ "additionalProperties": False,
625
+ }
626
+
627
+ _WORKSPACE_RESTORE_OUTPUT_SCHEMA = {
628
+ "type": "object",
629
+ "required": [
630
+ "kind",
631
+ "path",
632
+ "restored_revision_id",
633
+ "previous_sha256",
634
+ "sha256",
635
+ "bytes",
636
+ "updated_at",
637
+ ],
638
+ "properties": {
639
+ "kind": {"type": "string", "enum": list(VIRTUAL_WORKSPACE_KINDS)},
640
+ "path": {"type": "string"},
641
+ "restored_revision_id": {"type": "string"},
642
+ "previous_sha256": {"type": "string"},
643
+ "sha256": {"type": "string"},
644
+ "bytes": {"type": "number", "minimum": 0},
645
+ "updated_at": {"type": "string"},
646
+ },
647
+ "additionalProperties": False,
648
+ }
649
+
650
+ _MEMORY_PUT_OUTPUT_SCHEMA = {
651
+ "type": "object",
652
+ "required": ["backend", "namespace", "key", "stored", "ttl_seconds"],
653
+ "properties": {
654
+ "backend": {"type": "string", "enum": ["redis"]},
655
+ "namespace": {"type": "string"},
656
+ "key": {"type": "string"},
657
+ "stored": {"type": "boolean"},
658
+ "ttl_seconds": {"type": "string"},
659
+ },
660
+ "additionalProperties": False,
661
+ }
662
+
663
+ _MEMORY_GET_OUTPUT_SCHEMA = {
664
+ "type": "object",
665
+ "required": ["backend", "namespace", "key", "found", "value"],
666
+ "properties": {
667
+ "backend": {"type": "string", "enum": ["redis"]},
668
+ "namespace": {"type": "string"},
669
+ "key": {"type": "string"},
670
+ "found": {"type": "boolean"},
671
+ "value": {},
672
+ },
673
+ "additionalProperties": False,
674
+ }
675
+
676
+ _MEMORY_UPSERT_OUTPUT_SCHEMA = {
677
+ "type": "object",
678
+ "required": ["backend", "collection", "memory_id", "stored"],
679
+ "properties": {
680
+ "backend": {"type": "string", "enum": ["milvus"]},
681
+ "collection": {"type": "string"},
682
+ "memory_id": {"type": "string"},
683
+ "stored": {"type": "boolean"},
684
+ },
685
+ "additionalProperties": False,
686
+ }
687
+
688
+ _MEMORY_SEARCH_OUTPUT_SCHEMA = {
689
+ "type": "object",
690
+ "required": ["backend", "collection", "matches", "match_count"],
691
+ "properties": {
692
+ "backend": {"type": "string", "enum": ["milvus"]},
693
+ "collection": {"type": "string"},
694
+ "matches": {
695
+ "type": "array",
696
+ "items": {
697
+ "type": "object",
698
+ "required": ["memory_id", "text", "score", "metadata"],
699
+ "properties": {
700
+ "memory_id": {"type": "string"},
701
+ "text": {"type": "string"},
702
+ "score": {"type": "number"},
703
+ "metadata": {"type": "object"},
704
+ },
705
+ "additionalProperties": False,
706
+ },
707
+ },
708
+ "match_count": {"type": "number", "minimum": 0},
709
+ },
710
+ "additionalProperties": False,
711
+ }
712
+
713
+ _MEMORY_REMEMBER_OUTPUT_SCHEMA = {
714
+ "type": "object",
715
+ "required": [
716
+ "backend",
717
+ "collection",
718
+ "memory_id",
719
+ "stored",
720
+ "embedding_model",
721
+ "vector_dimensions",
722
+ ],
723
+ "properties": {
724
+ **_MEMORY_UPSERT_OUTPUT_SCHEMA["properties"],
725
+ "embedding_model": {"type": "string"},
726
+ "vector_dimensions": {"type": "string"},
727
+ },
728
+ "additionalProperties": False,
729
+ }
730
+
731
+ _MEMORY_RECALL_OUTPUT_SCHEMA = {
732
+ "type": "object",
733
+ "required": [
734
+ "backend",
735
+ "collection",
736
+ "matches",
737
+ "match_count",
738
+ "embedding_model",
739
+ "vector_dimensions",
740
+ ],
741
+ "properties": {
742
+ **_MEMORY_SEARCH_OUTPUT_SCHEMA["properties"],
743
+ "embedding_model": {"type": "string"},
744
+ "vector_dimensions": {"type": "string"},
745
+ },
746
+ "additionalProperties": False,
747
+ }
748
+
749
+ _DELEGATE_TASK_OUTPUT_SCHEMA = {
750
+ "type": "object",
751
+ "required": [
752
+ "child_run_id",
753
+ "status",
754
+ "answer",
755
+ "error_code",
756
+ "child_iteration_count",
757
+ "child_observation_count",
758
+ ],
759
+ "properties": {
760
+ "child_run_id": {"type": "string"},
761
+ "status": {
762
+ "type": "string",
763
+ "enum": ["done", "failed", "requires_approval", "cancelled"],
764
+ },
765
+ "answer": {"type": "string"},
766
+ "error_code": {"type": "string"},
767
+ "child_iteration_count": {"type": "string"},
768
+ "child_observation_count": {"type": "string"},
769
+ },
770
+ "additionalProperties": False,
771
+ }
772
+
773
+ _SKILL_LIST_OUTPUT_SCHEMA = {
774
+ "type": "object",
775
+ "required": ["skills", "skill_count"],
776
+ "properties": {
777
+ "skills": {
778
+ "type": "array",
779
+ "items": {
780
+ "type": "object",
781
+ "required": ["name", "description", "tags"],
782
+ "properties": {
783
+ "name": {"type": "string"},
784
+ "description": {"type": "string"},
785
+ "tags": {"type": "array", "items": {"type": "string"}},
786
+ },
787
+ "additionalProperties": False,
788
+ },
789
+ },
790
+ "skill_count": {"type": "number", "minimum": 0},
791
+ },
792
+ "additionalProperties": False,
793
+ }
794
+
795
+ _SKILL_GET_OUTPUT_SCHEMA = {
796
+ "type": "object",
797
+ "required": ["name", "description", "instructions", "tags"],
798
+ "properties": {
799
+ "name": {"type": "string"},
800
+ "description": {"type": "string"},
801
+ "instructions": {"type": "string"},
802
+ "tags": {"type": "array", "items": {"type": "string"}},
803
+ },
804
+ "additionalProperties": False,
805
+ }
806
+
807
+ _SHELL_COMMAND_OUTPUT_SCHEMA = {
808
+ "type": "object",
809
+ "required": [
810
+ "command",
811
+ "cwd",
812
+ "sandbox",
813
+ "exit_code",
814
+ "stdout",
815
+ "stderr",
816
+ "duration_seconds",
817
+ "timed_out",
818
+ "truncated",
819
+ ],
820
+ "properties": {
821
+ "command": {"type": "string"},
822
+ "cwd": {"type": "string"},
823
+ "sandbox": {
824
+ "type": "object",
825
+ "required": [
826
+ "enabled",
827
+ "backend",
828
+ "enforced",
829
+ "filesystem",
830
+ "network",
831
+ "env_policy",
832
+ ],
833
+ "properties": {
834
+ "enabled": {"type": "string", "enum": ["true"]},
835
+ "backend": {
836
+ "type": "string",
837
+ "enum": ["linux-bwrap", "macos-seatbelt", "soft", "windows-soft"],
838
+ },
839
+ "enforced": {"type": "string", "enum": ["true", "false"]},
840
+ "filesystem": {"type": "string", "enum": ["workspace"]},
841
+ "network": {"type": "string", "enum": ["disabled"]},
842
+ "env_policy": {"type": "string", "enum": ["minimal"]},
843
+ "fallback_reason": {"type": "string"},
844
+ },
845
+ "additionalProperties": False,
846
+ },
847
+ "exit_code": {"type": "number"},
848
+ "stdout": {"type": "string"},
849
+ "stderr": {"type": "string"},
850
+ "duration_seconds": {"type": "number", "minimum": 0},
851
+ "timed_out": {"type": "boolean"},
852
+ "truncated": {"type": "boolean"},
853
+ },
854
+ "additionalProperties": False,
855
+ }
856
+
857
+
858
+ def default_runtime_tools(
859
+ *,
860
+ runtime_workspace_dir: str = "",
861
+ runtime_skills_dir: str = "",
862
+ redis_url: str = "",
863
+ milvus_url: str = "",
864
+ embedding_base_url: str = "",
865
+ embedding_api_key: str = "",
866
+ embedding_model: str = "",
867
+ embedding_timeout_seconds: float = 30.0,
868
+ embedding_max_retries: int = 2,
869
+ embedding_retry_backoff_seconds: float = 0.25,
870
+ external_backend_timeout_seconds: float = 2.0,
871
+ delegate_runner: Callable[[str, int], Dict[str, Any]] | None = None,
872
+ include_delegate_tool: bool = True,
873
+ ) -> Dict[str, RuntimeToolSpec]:
874
+ active_redis_url = redis_url.strip() or _first_env_value(_REDIS_URL_ENV_VARS)
875
+ active_milvus_url = milvus_url.strip() or _first_env_value(_MILVUS_URL_ENV_VARS)
876
+ active_embedding_config = _embedding_config(
877
+ base_url=embedding_base_url,
878
+ api_key=embedding_api_key,
879
+ model=embedding_model,
880
+ timeout_seconds=embedding_timeout_seconds,
881
+ max_retries=embedding_max_retries,
882
+ retry_backoff_seconds=embedding_retry_backoff_seconds,
883
+ )
884
+ active_backend_timeout = _backend_timeout_seconds(external_backend_timeout_seconds)
885
+ tools = {
886
+ "apply_patch": RuntimeToolSpec(
887
+ name="apply_patch",
888
+ description=(
889
+ "Apply a Codex-style workspace patch. Supports adding, updating, "
890
+ "moving, and deleting files "
891
+ "inside the current workspace and rejects absolute paths, parent "
892
+ "traversal, unsafe deletes, and accidental overwrites. To create hello.md, "
893
+ "use exactly: *** Begin Patch\n*** Add File: hello.md\n+content\n*** End Patch"
894
+ ),
895
+ handler=_apply_patch,
896
+ input_schema={
897
+ "type": "object",
898
+ "required": ["patch"],
899
+ "properties": {
900
+ "patch": {
901
+ "type": "string",
902
+ "minLength": 1,
903
+ "maxLength": _APPLY_PATCH_MAX_BYTES,
904
+ },
905
+ },
906
+ "additionalProperties": False,
907
+ },
908
+ output_schema=_APPLY_PATCH_OUTPUT_SCHEMA,
909
+ ),
910
+ "patch_history": RuntimeToolSpec(
911
+ name="patch_history",
912
+ description=(
913
+ "List committed file-change checkpoints for the current workspace. "
914
+ "Use this before revert_patch and pass back the exact checkpoint ID "
915
+ "and paths selected by the user."
916
+ ),
917
+ handler=_patch_history,
918
+ input_schema={
919
+ "type": "object",
920
+ "properties": {
921
+ "limit": {"type": "number", "minimum": 1, "maximum": 100},
922
+ },
923
+ "additionalProperties": False,
924
+ },
925
+ output_schema=_PATCH_HISTORY_OUTPUT_SCHEMA,
926
+ ),
927
+ "revert_patch": RuntimeToolSpec(
928
+ name="revert_patch",
929
+ description=(
930
+ "Restore files from a reviewed patch checkpoint. The exact paths from "
931
+ "patch_history are required and current file SHA-256 values must still "
932
+ "match the checkpoint. The revert creates a new checkpoint for redo."
933
+ ),
934
+ handler=_revert_patch,
935
+ input_schema={
936
+ "type": "object",
937
+ "required": ["checkpoint_id", "paths"],
938
+ "properties": {
939
+ "checkpoint_id": {
940
+ "type": "string",
941
+ "minLength": 1,
942
+ "maxLength": 120,
943
+ },
944
+ "paths": {
945
+ "type": "array",
946
+ "minItems": 1,
947
+ "maxItems": 200,
948
+ "items": {"type": "string", "minLength": 1},
949
+ },
950
+ },
951
+ "additionalProperties": False,
952
+ },
953
+ output_schema=_REVERT_PATCH_OUTPUT_SCHEMA,
954
+ ),
955
+ "artifact": RuntimeToolSpec(
956
+ name="artifact",
957
+ description=(
958
+ "Record a structured artifact such as a report, plan, "
959
+ "decision, data, or message."
960
+ ),
961
+ handler=_artifact,
962
+ input_schema={
963
+ "type": "object",
964
+ "required": ["title", "kind", "content"],
965
+ "properties": {
966
+ "title": {
967
+ "type": "string",
968
+ "minLength": 1,
969
+ "maxLength": _SHORT_TEXT_MAX_LENGTH,
970
+ },
971
+ "kind": {
972
+ "type": "string",
973
+ "enum": list(_ARTIFACT_KINDS),
974
+ },
975
+ "content": {
976
+ "type": "string",
977
+ "minLength": 1,
978
+ "maxLength": _LONG_TEXT_MAX_LENGTH,
979
+ },
980
+ "format": {
981
+ "type": "string",
982
+ "enum": list(_ARTIFACT_FORMATS),
983
+ },
984
+ "tags": {
985
+ "type": "array",
986
+ "maxItems": _TAGS_MAX_ITEMS,
987
+ "items": {"type": "string"},
988
+ },
989
+ },
990
+ "additionalProperties": False,
991
+ },
992
+ output_schema=_ARTIFACT_OUTPUT_SCHEMA,
993
+ ),
994
+ "decision_matrix": RuntimeToolSpec(
995
+ name="decision_matrix",
996
+ description="Rank options with weighted criteria for structured decisions.",
997
+ handler=_decision_matrix,
998
+ input_schema={
999
+ "type": "object",
1000
+ "required": ["question", "criteria", "options"],
1001
+ "properties": {
1002
+ "question": {"type": "string"},
1003
+ "criteria": {
1004
+ "type": "array",
1005
+ "minItems": 1,
1006
+ "maxItems": _DECISION_CRITERIA_MAX_ITEMS,
1007
+ "items": {
1008
+ "type": "object",
1009
+ "required": ["name", "weight"],
1010
+ "properties": {
1011
+ "name": {
1012
+ "type": "string",
1013
+ "minLength": 1,
1014
+ "maxLength": _SHORT_TEXT_MAX_LENGTH,
1015
+ },
1016
+ "weight": {"type": "number", "minimum": 0},
1017
+ },
1018
+ "additionalProperties": False,
1019
+ },
1020
+ },
1021
+ "options": {
1022
+ "type": "array",
1023
+ "minItems": 2,
1024
+ "maxItems": _DECISION_OPTIONS_MAX_ITEMS,
1025
+ "items": {
1026
+ "type": "object",
1027
+ "required": ["name", "scores"],
1028
+ "properties": {
1029
+ "name": {
1030
+ "type": "string",
1031
+ "minLength": 1,
1032
+ "maxLength": _SHORT_TEXT_MAX_LENGTH,
1033
+ },
1034
+ "scores": {
1035
+ "type": "array",
1036
+ "minItems": 1,
1037
+ "items": {"type": "number"},
1038
+ },
1039
+ "rationale": {"type": "string"},
1040
+ },
1041
+ "additionalProperties": False,
1042
+ },
1043
+ },
1044
+ },
1045
+ "additionalProperties": False,
1046
+ },
1047
+ output_schema=_DECISION_MATRIX_OUTPUT_SCHEMA,
1048
+ ),
1049
+ "note": RuntimeToolSpec(
1050
+ name="note",
1051
+ description="Record a short note as an artifact observation.",
1052
+ handler=_note,
1053
+ input_schema={
1054
+ "type": "object",
1055
+ "required": ["text"],
1056
+ "properties": {
1057
+ "text": {"type": "string", "maxLength": _LONG_TEXT_MAX_LENGTH}
1058
+ },
1059
+ "additionalProperties": False,
1060
+ },
1061
+ output_schema=_TEXT_OUTPUT_SCHEMA,
1062
+ ),
1063
+ "http_request": RuntimeToolSpec(
1064
+ name="http_request",
1065
+ description=(
1066
+ "Fetch a URL with an HTTP GET request. This does not open a "
1067
+ "browser window; use open_url when the user asks to open a "
1068
+ "web page. This tool is policy-gated and should only run after "
1069
+ "explicit approval. Private, loopback, and link-local targets "
1070
+ "are rejected."
1071
+ ),
1072
+ handler=_http_request,
1073
+ input_schema={
1074
+ "type": "object",
1075
+ "required": ["url"],
1076
+ "properties": {
1077
+ "url": {
1078
+ "type": "string",
1079
+ "minLength": 1,
1080
+ "maxLength": 2048,
1081
+ },
1082
+ "max_bytes": {
1083
+ "type": "number",
1084
+ "minimum": 1,
1085
+ "maximum": _HTTP_REQUEST_MAX_BYTES,
1086
+ },
1087
+ },
1088
+ "additionalProperties": False,
1089
+ },
1090
+ output_schema=_HTTP_REQUEST_OUTPUT_SCHEMA,
1091
+ ),
1092
+ "list_files": RuntimeToolSpec(
1093
+ name="list_files",
1094
+ description=(
1095
+ "List files and directories inside the current workspace with "
1096
+ "bounded depth and entry count."
1097
+ ),
1098
+ handler=_list_files,
1099
+ input_schema={
1100
+ "type": "object",
1101
+ "properties": {
1102
+ "path": {"type": "string", "maxLength": 2048},
1103
+ "max_depth": {
1104
+ "type": "number",
1105
+ "minimum": 0,
1106
+ "maximum": _LIST_FILES_MAX_DEPTH,
1107
+ },
1108
+ "limit": {
1109
+ "type": "number",
1110
+ "minimum": 1,
1111
+ "maximum": _LIST_FILES_MAX_ENTRIES,
1112
+ },
1113
+ },
1114
+ "additionalProperties": False,
1115
+ },
1116
+ output_schema=_LIST_FILES_OUTPUT_SCHEMA,
1117
+ ),
1118
+ "open_url": RuntimeToolSpec(
1119
+ name="open_url",
1120
+ description=(
1121
+ "Open an http:// or https:// URL in a local browser window on "
1122
+ "macOS. Uses Google Chrome automation first, with macOS open "
1123
+ "fallbacks. Use this when the user asks to open a web page."
1124
+ ),
1125
+ handler=_open_url,
1126
+ input_schema={
1127
+ "type": "object",
1128
+ "required": ["url"],
1129
+ "properties": {
1130
+ "url": {
1131
+ "type": "string",
1132
+ "minLength": 1,
1133
+ "maxLength": 2048,
1134
+ },
1135
+ },
1136
+ "additionalProperties": False,
1137
+ },
1138
+ output_schema=_OPEN_URL_OUTPUT_SCHEMA,
1139
+ ),
1140
+ "open_app": RuntimeToolSpec(
1141
+ name="open_app",
1142
+ description=(
1143
+ "Open a local macOS application by application name. Use this "
1144
+ "when the user asks to open an installed app such as Chrome, "
1145
+ "Cursor, WeChat, Feishu, or Terminal. The input is an app name, "
1146
+ "not a path or shell command."
1147
+ ),
1148
+ handler=_open_app,
1149
+ input_schema={
1150
+ "type": "object",
1151
+ "required": ["application"],
1152
+ "properties": {
1153
+ "application": {
1154
+ "type": "string",
1155
+ "minLength": 1,
1156
+ "maxLength": _APP_NAME_MAX_LENGTH,
1157
+ },
1158
+ },
1159
+ "additionalProperties": False,
1160
+ },
1161
+ output_schema=_OPEN_APP_OUTPUT_SCHEMA,
1162
+ ),
1163
+ "read_file": RuntimeToolSpec(
1164
+ name="read_file",
1165
+ description=(
1166
+ "Read a UTF-8 text file inside the current workspace with a "
1167
+ "bounded byte limit."
1168
+ ),
1169
+ handler=_read_file,
1170
+ input_schema={
1171
+ "type": "object",
1172
+ "required": ["path"],
1173
+ "properties": {
1174
+ "path": {"type": "string", "minLength": 1, "maxLength": 2048},
1175
+ "max_bytes": {
1176
+ "type": "number",
1177
+ "minimum": 1,
1178
+ "maximum": _READ_FILE_MAX_BYTES,
1179
+ },
1180
+ },
1181
+ "additionalProperties": False,
1182
+ },
1183
+ output_schema=_READ_FILE_OUTPUT_SCHEMA,
1184
+ ),
1185
+ "workspace_write": RuntimeToolSpec(
1186
+ name="workspace_write",
1187
+ description=(
1188
+ "Write UTF-8 text into the runtime virtual workspace. "
1189
+ "Kinds are workspace, reports, logs, policies, and memories. "
1190
+ "Paths are relative to the selected virtual directory and "
1191
+ "cannot escape it."
1192
+ ),
1193
+ handler=lambda payload: _workspace_write(
1194
+ payload,
1195
+ runtime_workspace_dir=runtime_workspace_dir,
1196
+ ),
1197
+ input_schema={
1198
+ "type": "object",
1199
+ "required": ["kind", "path", "content"],
1200
+ "properties": {
1201
+ "kind": {
1202
+ "type": "string",
1203
+ "enum": list(VIRTUAL_WORKSPACE_KINDS),
1204
+ },
1205
+ "path": {"type": "string", "minLength": 1, "maxLength": 2048},
1206
+ "content": {
1207
+ "type": "string",
1208
+ "minLength": 1,
1209
+ "maxLength": _LONG_TEXT_MAX_LENGTH,
1210
+ },
1211
+ "metadata": {"type": "object"},
1212
+ },
1213
+ "additionalProperties": False,
1214
+ },
1215
+ output_schema=_WORKSPACE_ASSET_METADATA_SCHEMA,
1216
+ ),
1217
+ "workspace_read": RuntimeToolSpec(
1218
+ name="workspace_read",
1219
+ description="Read a UTF-8 text asset from the runtime virtual workspace.",
1220
+ handler=lambda payload: _workspace_read(
1221
+ payload,
1222
+ runtime_workspace_dir=runtime_workspace_dir,
1223
+ ),
1224
+ input_schema={
1225
+ "type": "object",
1226
+ "required": ["kind", "path"],
1227
+ "properties": {
1228
+ "kind": {
1229
+ "type": "string",
1230
+ "enum": list(VIRTUAL_WORKSPACE_KINDS),
1231
+ },
1232
+ "path": {"type": "string", "minLength": 1, "maxLength": 2048},
1233
+ "max_bytes": {
1234
+ "type": "number",
1235
+ "minimum": 1,
1236
+ "maximum": _READ_FILE_MAX_BYTES,
1237
+ },
1238
+ },
1239
+ "additionalProperties": False,
1240
+ },
1241
+ output_schema=_WORKSPACE_READ_OUTPUT_SCHEMA,
1242
+ ),
1243
+ "workspace_history": RuntimeToolSpec(
1244
+ name="workspace_history",
1245
+ description=(
1246
+ "Read previous versions saved before overwriting a UTF-8 text "
1247
+ "asset in the runtime virtual workspace."
1248
+ ),
1249
+ handler=lambda payload: _workspace_history(
1250
+ payload,
1251
+ runtime_workspace_dir=runtime_workspace_dir,
1252
+ ),
1253
+ input_schema={
1254
+ "type": "object",
1255
+ "required": ["kind", "path"],
1256
+ "properties": {
1257
+ "kind": {
1258
+ "type": "string",
1259
+ "enum": list(VIRTUAL_WORKSPACE_KINDS),
1260
+ },
1261
+ "path": {"type": "string", "minLength": 1, "maxLength": 2048},
1262
+ "limit": {"type": "number", "minimum": 1, "maximum": 50},
1263
+ "max_bytes": {
1264
+ "type": "number",
1265
+ "minimum": 1,
1266
+ "maximum": _READ_FILE_MAX_BYTES,
1267
+ },
1268
+ },
1269
+ "additionalProperties": False,
1270
+ },
1271
+ output_schema=_WORKSPACE_HISTORY_OUTPUT_SCHEMA,
1272
+ ),
1273
+ "workspace_diff": RuntimeToolSpec(
1274
+ name="workspace_diff",
1275
+ description=(
1276
+ "Compare the current UTF-8 text asset in the runtime virtual "
1277
+ "workspace against a saved previous version and return a bounded "
1278
+ "unified diff."
1279
+ ),
1280
+ handler=lambda payload: _workspace_diff(
1281
+ payload,
1282
+ runtime_workspace_dir=runtime_workspace_dir,
1283
+ ),
1284
+ input_schema={
1285
+ "type": "object",
1286
+ "required": ["kind", "path"],
1287
+ "properties": {
1288
+ "kind": {
1289
+ "type": "string",
1290
+ "enum": list(VIRTUAL_WORKSPACE_KINDS),
1291
+ },
1292
+ "path": {"type": "string", "minLength": 1, "maxLength": 2048},
1293
+ "revision_id": {"type": "string", "maxLength": 120},
1294
+ "context_lines": {"type": "number", "minimum": 0, "maximum": 20},
1295
+ "max_bytes": {
1296
+ "type": "number",
1297
+ "minimum": 1,
1298
+ "maximum": _READ_FILE_MAX_BYTES,
1299
+ },
1300
+ },
1301
+ "additionalProperties": False,
1302
+ },
1303
+ output_schema=_WORKSPACE_DIFF_OUTPUT_SCHEMA,
1304
+ ),
1305
+ "workspace_restore": RuntimeToolSpec(
1306
+ name="workspace_restore",
1307
+ description=(
1308
+ "Restore a runtime virtual workspace text asset to a reviewed "
1309
+ "saved revision. Requires the expected current SHA-256 so a "
1310
+ "concurrent edit cannot be overwritten silently. The current "
1311
+ "content is saved as a new revision before restoration."
1312
+ ),
1313
+ handler=lambda payload: _workspace_restore(
1314
+ payload,
1315
+ runtime_workspace_dir=runtime_workspace_dir,
1316
+ ),
1317
+ input_schema={
1318
+ "type": "object",
1319
+ "required": [
1320
+ "kind",
1321
+ "path",
1322
+ "revision_id",
1323
+ "expected_current_sha256",
1324
+ "expected_revision_sha256",
1325
+ ],
1326
+ "properties": {
1327
+ "kind": {
1328
+ "type": "string",
1329
+ "enum": list(VIRTUAL_WORKSPACE_KINDS),
1330
+ },
1331
+ "path": {"type": "string", "minLength": 1, "maxLength": 2048},
1332
+ "revision_id": {"type": "string", "minLength": 1, "maxLength": 120},
1333
+ "expected_current_sha256": {
1334
+ "type": "string",
1335
+ "minLength": 64,
1336
+ "maxLength": 64,
1337
+ },
1338
+ "expected_revision_sha256": {
1339
+ "type": "string",
1340
+ "minLength": 64,
1341
+ "maxLength": 64,
1342
+ },
1343
+ },
1344
+ "additionalProperties": False,
1345
+ },
1346
+ output_schema=_WORKSPACE_RESTORE_OUTPUT_SCHEMA,
1347
+ ),
1348
+ "workspace_list": RuntimeToolSpec(
1349
+ name="workspace_list",
1350
+ description=(
1351
+ "List assets inside a runtime virtual workspace directory with "
1352
+ "bounded depth and entry count."
1353
+ ),
1354
+ handler=lambda payload: _workspace_list(
1355
+ payload,
1356
+ runtime_workspace_dir=runtime_workspace_dir,
1357
+ ),
1358
+ input_schema={
1359
+ "type": "object",
1360
+ "required": ["kind"],
1361
+ "properties": {
1362
+ "kind": {
1363
+ "type": "string",
1364
+ "enum": list(VIRTUAL_WORKSPACE_KINDS),
1365
+ },
1366
+ "path": {"type": "string", "maxLength": 2048},
1367
+ "max_depth": {
1368
+ "type": "number",
1369
+ "minimum": 0,
1370
+ "maximum": _LIST_FILES_MAX_DEPTH,
1371
+ },
1372
+ "limit": {
1373
+ "type": "number",
1374
+ "minimum": 1,
1375
+ "maximum": _LIST_FILES_MAX_ENTRIES,
1376
+ },
1377
+ },
1378
+ "additionalProperties": False,
1379
+ },
1380
+ output_schema=_WORKSPACE_LIST_OUTPUT_SCHEMA,
1381
+ ),
1382
+ "workspace_search": RuntimeToolSpec(
1383
+ name="workspace_search",
1384
+ description=(
1385
+ "Search UTF-8 text assets inside a runtime virtual workspace "
1386
+ "directory with bounded depth, byte, and result limits."
1387
+ ),
1388
+ handler=lambda payload: _workspace_search(
1389
+ payload,
1390
+ runtime_workspace_dir=runtime_workspace_dir,
1391
+ ),
1392
+ input_schema={
1393
+ "type": "object",
1394
+ "required": ["kind", "query"],
1395
+ "properties": {
1396
+ "kind": {
1397
+ "type": "string",
1398
+ "enum": list(VIRTUAL_WORKSPACE_KINDS),
1399
+ },
1400
+ "query": {"type": "string", "minLength": 1, "maxLength": 500},
1401
+ "path": {"type": "string", "maxLength": 2048},
1402
+ "max_depth": {
1403
+ "type": "number",
1404
+ "minimum": 0,
1405
+ "maximum": _LIST_FILES_MAX_DEPTH,
1406
+ },
1407
+ "limit": {"type": "number", "minimum": 1, "maximum": 50},
1408
+ "max_bytes": {
1409
+ "type": "number",
1410
+ "minimum": 1,
1411
+ "maximum": _READ_FILE_MAX_BYTES,
1412
+ },
1413
+ },
1414
+ "additionalProperties": False,
1415
+ },
1416
+ output_schema=_WORKSPACE_SEARCH_OUTPUT_SCHEMA,
1417
+ ),
1418
+ "memory_put": RuntimeToolSpec(
1419
+ name="memory_put",
1420
+ description=(
1421
+ "Store short-term structured runtime memory in Redis by namespace "
1422
+ "and key. Requires KAGENT_REDIS_URL or service redis_url."
1423
+ ),
1424
+ handler=lambda payload: _memory_put(
1425
+ payload,
1426
+ redis_url=active_redis_url,
1427
+ timeout_seconds=active_backend_timeout,
1428
+ ),
1429
+ input_schema={
1430
+ "type": "object",
1431
+ "required": ["namespace", "key", "value"],
1432
+ "properties": {
1433
+ "namespace": {"type": "string", "minLength": 1, "maxLength": 120},
1434
+ "key": {"type": "string", "minLength": 1, "maxLength": 120},
1435
+ "value": {},
1436
+ "ttl_seconds": {"type": "number", "minimum": 0},
1437
+ },
1438
+ "additionalProperties": False,
1439
+ },
1440
+ output_schema=_MEMORY_PUT_OUTPUT_SCHEMA,
1441
+ ),
1442
+ "memory_get": RuntimeToolSpec(
1443
+ name="memory_get",
1444
+ description=(
1445
+ "Read short-term structured runtime memory from Redis by "
1446
+ "namespace and key."
1447
+ ),
1448
+ handler=lambda payload: _memory_get(
1449
+ payload,
1450
+ redis_url=active_redis_url,
1451
+ timeout_seconds=active_backend_timeout,
1452
+ ),
1453
+ input_schema={
1454
+ "type": "object",
1455
+ "required": ["namespace", "key"],
1456
+ "properties": {
1457
+ "namespace": {"type": "string", "minLength": 1, "maxLength": 120},
1458
+ "key": {"type": "string", "minLength": 1, "maxLength": 120},
1459
+ },
1460
+ "additionalProperties": False,
1461
+ },
1462
+ output_schema=_MEMORY_GET_OUTPUT_SCHEMA,
1463
+ ),
1464
+ "memory_upsert": RuntimeToolSpec(
1465
+ name="memory_upsert",
1466
+ description=(
1467
+ "Store long-term semantic memory in Milvus. The caller must "
1468
+ "provide an embedding vector; this runtime does not invent "
1469
+ "vectors from text."
1470
+ ),
1471
+ handler=lambda payload: _memory_upsert(
1472
+ payload,
1473
+ milvus_url=active_milvus_url,
1474
+ timeout_seconds=active_backend_timeout,
1475
+ ),
1476
+ input_schema={
1477
+ "type": "object",
1478
+ "required": ["collection", "memory_id", "text", "vector"],
1479
+ "properties": {
1480
+ "collection": {"type": "string", "minLength": 1, "maxLength": 120},
1481
+ "memory_id": {"type": "string", "minLength": 1, "maxLength": 120},
1482
+ "text": {"type": "string", "minLength": 1, "maxLength": 20000},
1483
+ "vector": {
1484
+ "type": "array",
1485
+ "minItems": 1,
1486
+ "maxItems": 4096,
1487
+ "items": {"type": "number"},
1488
+ },
1489
+ "metadata": {"type": "object"},
1490
+ },
1491
+ "additionalProperties": False,
1492
+ },
1493
+ output_schema=_MEMORY_UPSERT_OUTPUT_SCHEMA,
1494
+ ),
1495
+ "memory_search": RuntimeToolSpec(
1496
+ name="memory_search",
1497
+ description=(
1498
+ "Search long-term semantic memory in Milvus with an embedding "
1499
+ "vector. Requires KAGENT_MILVUS_URL or service milvus_url."
1500
+ ),
1501
+ handler=lambda payload: _memory_search(
1502
+ payload,
1503
+ milvus_url=active_milvus_url,
1504
+ timeout_seconds=active_backend_timeout,
1505
+ ),
1506
+ input_schema={
1507
+ "type": "object",
1508
+ "required": ["collection", "vector"],
1509
+ "properties": {
1510
+ "collection": {"type": "string", "minLength": 1, "maxLength": 120},
1511
+ "vector": {
1512
+ "type": "array",
1513
+ "minItems": 1,
1514
+ "maxItems": 4096,
1515
+ "items": {"type": "number"},
1516
+ },
1517
+ "limit": {"type": "number", "minimum": 1, "maximum": 50},
1518
+ },
1519
+ "additionalProperties": False,
1520
+ },
1521
+ output_schema=_MEMORY_SEARCH_OUTPUT_SCHEMA,
1522
+ ),
1523
+ "memory_remember": RuntimeToolSpec(
1524
+ name="memory_remember",
1525
+ description=(
1526
+ "Embed text with the configured OpenAI-compatible embedding "
1527
+ "provider and store it in Milvus long-term memory."
1528
+ ),
1529
+ handler=lambda payload: _memory_remember(
1530
+ payload,
1531
+ milvus_url=active_milvus_url,
1532
+ embedding_config=active_embedding_config,
1533
+ timeout_seconds=active_backend_timeout,
1534
+ ),
1535
+ input_schema={
1536
+ "type": "object",
1537
+ "required": ["collection", "memory_id", "text"],
1538
+ "properties": {
1539
+ "collection": {"type": "string", "minLength": 1, "maxLength": 120},
1540
+ "memory_id": {"type": "string", "minLength": 1, "maxLength": 120},
1541
+ "text": {"type": "string", "minLength": 1, "maxLength": 20000},
1542
+ "metadata": {"type": "object"},
1543
+ },
1544
+ "additionalProperties": False,
1545
+ },
1546
+ output_schema=_MEMORY_REMEMBER_OUTPUT_SCHEMA,
1547
+ ),
1548
+ "memory_recall": RuntimeToolSpec(
1549
+ name="memory_recall",
1550
+ description=(
1551
+ "Embed a text query with the configured OpenAI-compatible "
1552
+ "embedding provider and search Milvus long-term memory."
1553
+ ),
1554
+ handler=lambda payload: _memory_recall(
1555
+ payload,
1556
+ milvus_url=active_milvus_url,
1557
+ embedding_config=active_embedding_config,
1558
+ timeout_seconds=active_backend_timeout,
1559
+ ),
1560
+ input_schema={
1561
+ "type": "object",
1562
+ "required": ["collection", "query"],
1563
+ "properties": {
1564
+ "collection": {"type": "string", "minLength": 1, "maxLength": 120},
1565
+ "query": {"type": "string", "minLength": 1, "maxLength": 20000},
1566
+ "limit": {"type": "number", "minimum": 1, "maximum": 50},
1567
+ },
1568
+ "additionalProperties": False,
1569
+ },
1570
+ output_schema=_MEMORY_RECALL_OUTPUT_SCHEMA,
1571
+ ),
1572
+ "shell_command": RuntimeToolSpec(
1573
+ name="shell_command",
1574
+ description=(
1575
+ "Run a bounded non-interactive shell command inside the current "
1576
+ "workspace. Captures stdout/stderr, returns the process exit code, "
1577
+ "rejects workspace escapes and obvious interactive/background "
1578
+ "commands, and is policy-gated by default."
1579
+ ),
1580
+ handler=_shell_command,
1581
+ input_schema={
1582
+ "type": "object",
1583
+ "required": ["command"],
1584
+ "properties": {
1585
+ "command": {
1586
+ "type": "string",
1587
+ "minLength": 1,
1588
+ "maxLength": _SHELL_COMMAND_MAX_LENGTH,
1589
+ },
1590
+ "cwd": {"type": "string", "maxLength": 2048},
1591
+ "timeout_seconds": {
1592
+ "type": "number",
1593
+ "minimum": 1,
1594
+ "maximum": _SHELL_COMMAND_MAX_TIMEOUT_SECONDS,
1595
+ },
1596
+ "max_output_bytes": {
1597
+ "type": "number",
1598
+ "minimum": 1,
1599
+ "maximum": _SHELL_COMMAND_MAX_OUTPUT_BYTES,
1600
+ },
1601
+ },
1602
+ "additionalProperties": False,
1603
+ },
1604
+ output_schema=_SHELL_COMMAND_OUTPUT_SCHEMA,
1605
+ timeout_seconds=_SHELL_COMMAND_MAX_TIMEOUT_SECONDS + 1,
1606
+ ),
1607
+ "skill_list": RuntimeToolSpec(
1608
+ name="skill_list",
1609
+ description="List installed runtime skills available to the agent.",
1610
+ handler=lambda payload: _skill_list(
1611
+ payload,
1612
+ runtime_skills_dir=runtime_skills_dir,
1613
+ ),
1614
+ input_schema={
1615
+ "type": "object",
1616
+ "properties": {},
1617
+ "additionalProperties": False,
1618
+ },
1619
+ output_schema=_SKILL_LIST_OUTPUT_SCHEMA,
1620
+ ),
1621
+ "skill_get": RuntimeToolSpec(
1622
+ name="skill_get",
1623
+ description="Read one installed runtime skill's instructions by name.",
1624
+ handler=lambda payload: _skill_get(
1625
+ payload,
1626
+ runtime_skills_dir=runtime_skills_dir,
1627
+ ),
1628
+ input_schema={
1629
+ "type": "object",
1630
+ "required": ["name"],
1631
+ "properties": {
1632
+ "name": {"type": "string", "minLength": 1, "maxLength": 80},
1633
+ },
1634
+ "additionalProperties": False,
1635
+ },
1636
+ output_schema=_SKILL_GET_OUTPUT_SCHEMA,
1637
+ ),
1638
+ "task_list": RuntimeToolSpec(
1639
+ name="task_list",
1640
+ description="Create a structured task list with normalized status counts.",
1641
+ handler=_task_list,
1642
+ input_schema={
1643
+ "type": "object",
1644
+ "required": ["items"],
1645
+ "properties": {
1646
+ "items": {
1647
+ "type": "array",
1648
+ "minItems": 1,
1649
+ "maxItems": _TASK_ITEMS_MAX_ITEMS,
1650
+ "items": {
1651
+ "type": "object",
1652
+ "required": ["title"],
1653
+ "properties": {
1654
+ "title": {
1655
+ "type": "string",
1656
+ "minLength": 1,
1657
+ "maxLength": _TASK_TITLE_MAX_LENGTH,
1658
+ },
1659
+ "status": {
1660
+ "type": "string",
1661
+ "enum": list(_TASK_STATUSES),
1662
+ },
1663
+ "priority": {
1664
+ "type": "string",
1665
+ "enum": list(_TASK_PRIORITIES),
1666
+ },
1667
+ "owner": {"type": "string"},
1668
+ "due": {"type": "string"},
1669
+ },
1670
+ "additionalProperties": False,
1671
+ },
1672
+ },
1673
+ },
1674
+ "additionalProperties": False,
1675
+ },
1676
+ output_schema=_TASK_LIST_OUTPUT_SCHEMA,
1677
+ ),
1678
+ "task_transition": RuntimeToolSpec(
1679
+ name="task_transition",
1680
+ description=(
1681
+ "Advance one task through the runtime task lifecycle using a "
1682
+ "validated state-machine transition."
1683
+ ),
1684
+ handler=_task_transition,
1685
+ input_schema={
1686
+ "type": "object",
1687
+ "required": ["state", "event"],
1688
+ "properties": {
1689
+ "state": {"type": "string", "enum": list(TASK_STATES)},
1690
+ "event": {"type": "string", "enum": list(TASK_EVENTS)},
1691
+ },
1692
+ "additionalProperties": False,
1693
+ },
1694
+ output_schema=_TASK_TRANSITION_OUTPUT_SCHEMA,
1695
+ ),
1696
+ "rubric_score": RuntimeToolSpec(
1697
+ name="rubric_score",
1698
+ description=(
1699
+ "Score a result against pass/fail rubric criteria and report "
1700
+ "blocking failures."
1701
+ ),
1702
+ handler=_rubric_score,
1703
+ input_schema={
1704
+ "type": "object",
1705
+ "required": ["criteria"],
1706
+ "properties": {
1707
+ "criteria": {
1708
+ "type": "array",
1709
+ "minItems": 1,
1710
+ "maxItems": _RUBRIC_CRITERIA_MAX_ITEMS,
1711
+ "items": {
1712
+ "type": "object",
1713
+ "required": ["name", "passed"],
1714
+ "properties": {
1715
+ "name": {
1716
+ "type": "string",
1717
+ "minLength": 1,
1718
+ "maxLength": _SHORT_TEXT_MAX_LENGTH,
1719
+ },
1720
+ "passed": {"type": "boolean"},
1721
+ "severity": {
1722
+ "type": "string",
1723
+ "enum": list(_RUBRIC_SEVERITIES),
1724
+ },
1725
+ "evidence": {
1726
+ "type": "string",
1727
+ "maxLength": _LONG_TEXT_MAX_LENGTH,
1728
+ },
1729
+ },
1730
+ "additionalProperties": False,
1731
+ },
1732
+ },
1733
+ },
1734
+ "additionalProperties": False,
1735
+ },
1736
+ output_schema=_RUBRIC_SCORE_OUTPUT_SCHEMA,
1737
+ ),
1738
+ "transform_text": RuntimeToolSpec(
1739
+ name="transform_text",
1740
+ description="Transform text with uppercase, lowercase, reverse, or trim modes.",
1741
+ handler=_transform_text,
1742
+ input_schema={
1743
+ "type": "object",
1744
+ "required": ["text", "mode"],
1745
+ "properties": {
1746
+ "text": {"type": "string"},
1747
+ "mode": {
1748
+ "type": "string",
1749
+ "enum": ["uppercase", "lowercase", "reverse", "trim"],
1750
+ },
1751
+ },
1752
+ "additionalProperties": False,
1753
+ },
1754
+ output_schema=_TEXT_OUTPUT_SCHEMA,
1755
+ ),
1756
+ }
1757
+ if include_delegate_tool:
1758
+ tools["delegate_task"] = RuntimeToolSpec(
1759
+ name="delegate_task",
1760
+ description=(
1761
+ "Delegate a bounded subtask to a child kagent runtime. Child "
1762
+ "runs return a compact summary and cannot delegate again by default."
1763
+ ),
1764
+ handler=lambda payload: _delegate_task(
1765
+ payload,
1766
+ delegate_runner=delegate_runner,
1767
+ ),
1768
+ input_schema={
1769
+ "type": "object",
1770
+ "required": ["goal"],
1771
+ "properties": {
1772
+ "goal": {
1773
+ "type": "string",
1774
+ "minLength": 1,
1775
+ "maxLength": _DELEGATE_GOAL_MAX_LENGTH,
1776
+ },
1777
+ "max_iterations": {
1778
+ "type": "number",
1779
+ "minimum": 1,
1780
+ "maximum": _DELEGATE_MAX_ITERATIONS,
1781
+ },
1782
+ },
1783
+ "additionalProperties": False,
1784
+ },
1785
+ output_schema=_DELEGATE_TASK_OUTPUT_SCHEMA,
1786
+ timeout_seconds=30.0,
1787
+ )
1788
+ return tools
1789
+
1790
+
1791
+ def registered_runtime_tool_metadata() -> list[Dict[str, Any]]:
1792
+ return runtime_tool_metadata(default_runtime_tools())
1793
+
1794
+
1795
+ def runtime_tool_metadata(tools: Dict[str, RuntimeToolSpec]) -> list[Dict[str, Any]]:
1796
+ default_allowed_tools = RuntimePolicy().allowed_tools
1797
+ return [
1798
+ {
1799
+ "name": tool.name,
1800
+ "description": tool.description,
1801
+ "approval_required_by_default": str(
1802
+ tool.name not in default_allowed_tools
1803
+ ).lower(),
1804
+ "input_schema": tool.input_schema,
1805
+ "output_schema": tool.output_schema,
1806
+ "timeout_seconds": f"{tool.timeout_seconds:.1f}",
1807
+ }
1808
+ for tool in sorted(tools.values(), key=lambda item: item.name)
1809
+ ]
1810
+
1811
+
1812
+ def execute_runtime_tool(
1813
+ registry: Dict[str, RuntimeToolSpec],
1814
+ tool_name: str,
1815
+ input_payload: Dict[str, Any],
1816
+ *,
1817
+ action_id: str = "",
1818
+ ) -> AgentObservation:
1819
+ started_at = _utc_timestamp()
1820
+ started_timer = time.perf_counter()
1821
+ tool = registry.get(tool_name)
1822
+ if tool is None:
1823
+ return AgentObservation(
1824
+ action_id=action_id,
1825
+ tool=tool_name,
1826
+ status="failed",
1827
+ output={},
1828
+ error_code="tool_not_found",
1829
+ error="tool is not registered",
1830
+ started_at=started_at,
1831
+ completed_at=_utc_timestamp(),
1832
+ duration_seconds=_duration_since(started_timer),
1833
+ )
1834
+ try:
1835
+ _validate_tool_input(input_payload, tool.input_schema)
1836
+ output = _run_tool_handler(tool, input_payload)
1837
+ except TimeoutError as exc:
1838
+ return AgentObservation(
1839
+ action_id=action_id,
1840
+ tool=tool_name,
1841
+ status="failed",
1842
+ output={},
1843
+ error_code="tool_execution_timeout",
1844
+ error=str(exc) or "tool execution exceeded timeout",
1845
+ started_at=started_at,
1846
+ completed_at=_utc_timestamp(),
1847
+ duration_seconds=_duration_since(started_timer),
1848
+ )
1849
+ except ValueError as exc:
1850
+ return AgentObservation(
1851
+ action_id=action_id,
1852
+ tool=tool_name,
1853
+ status="failed",
1854
+ output={},
1855
+ error_code="invalid_tool_input",
1856
+ error=str(exc),
1857
+ started_at=started_at,
1858
+ completed_at=_utc_timestamp(),
1859
+ duration_seconds=_duration_since(started_timer),
1860
+ )
1861
+ except Exception as exc:
1862
+ return AgentObservation(
1863
+ action_id=action_id,
1864
+ tool=tool_name,
1865
+ status="failed",
1866
+ output={},
1867
+ error_code="tool_execution_failed",
1868
+ error=str(exc) or type(exc).__name__,
1869
+ started_at=started_at,
1870
+ completed_at=_utc_timestamp(),
1871
+ duration_seconds=_duration_since(started_timer),
1872
+ )
1873
+ try:
1874
+ _validate_tool_input(output, tool.output_schema, "output")
1875
+ except ValueError as exc:
1876
+ return AgentObservation(
1877
+ action_id=action_id,
1878
+ tool=tool_name,
1879
+ status="failed",
1880
+ output={},
1881
+ error_code="invalid_tool_output",
1882
+ error=str(exc),
1883
+ started_at=started_at,
1884
+ completed_at=_utc_timestamp(),
1885
+ duration_seconds=_duration_since(started_timer),
1886
+ )
1887
+ return AgentObservation(
1888
+ action_id=action_id,
1889
+ tool=tool_name,
1890
+ status="ok",
1891
+ output=output,
1892
+ started_at=started_at,
1893
+ completed_at=_utc_timestamp(),
1894
+ duration_seconds=_duration_since(started_timer),
1895
+ )
1896
+
1897
+
1898
+ def _run_tool_handler(
1899
+ tool: RuntimeToolSpec,
1900
+ input_payload: Dict[str, Any],
1901
+ ) -> Dict[str, Any]:
1902
+ output = tool.handler(input_payload)
1903
+ if not isinstance(output, dict):
1904
+ raise ValueError("output must be an object")
1905
+ return output
1906
+
1907
+
1908
+ def _note(input_payload: Dict[str, Any]) -> Dict[str, Any]:
1909
+ text = input_payload.get("text")
1910
+ if not isinstance(text, str):
1911
+ raise ValueError("text must be a string")
1912
+ return {"text": text}
1913
+
1914
+
1915
+ def _read_file(input_payload: Dict[str, Any]) -> Dict[str, Any]:
1916
+ relative_path = input_payload.get("path")
1917
+ if not isinstance(relative_path, str) or not relative_path.strip():
1918
+ raise ValueError("path must be a non-empty string")
1919
+ normalized_path = relative_path.strip()
1920
+ max_bytes = int(input_payload.get("max_bytes", _READ_FILE_MAX_BYTES))
1921
+ workspace_root = Path.cwd().resolve()
1922
+ target = _resolve_workspace_relative_path(workspace_root, normalized_path)
1923
+ if not target.exists():
1924
+ raise ValueError(f"file does not exist: {normalized_path}")
1925
+ if target.is_dir():
1926
+ raise ValueError(f"path is a directory: {normalized_path}")
1927
+ body = target.read_bytes()
1928
+ truncated = len(body) > max_bytes
1929
+ visible_body = body[:max_bytes] if truncated else body
1930
+ return {
1931
+ "path": _workspace_output_path(workspace_root, target),
1932
+ "content": visible_body.decode("utf-8", errors="replace"),
1933
+ "bytes": len(visible_body),
1934
+ "truncated": truncated,
1935
+ "sha256": hashlib.sha256(body).hexdigest(),
1936
+ }
1937
+
1938
+
1939
+ def _list_files(input_payload: Dict[str, Any]) -> Dict[str, Any]:
1940
+ relative_path = input_payload.get("path", ".")
1941
+ if not isinstance(relative_path, str) or not relative_path.strip():
1942
+ raise ValueError("path must be a non-empty string")
1943
+ normalized_path = relative_path.strip()
1944
+ max_depth = int(input_payload.get("max_depth", _LIST_FILES_MAX_DEPTH))
1945
+ limit = int(input_payload.get("limit", _LIST_FILES_MAX_ENTRIES))
1946
+ workspace_root = Path.cwd().resolve()
1947
+ root = _resolve_workspace_relative_path(workspace_root, normalized_path)
1948
+ if not root.exists():
1949
+ raise ValueError(f"path does not exist: {normalized_path}")
1950
+ if root.is_file():
1951
+ entries = [_file_entry(workspace_root, root)]
1952
+ return {
1953
+ "root": _workspace_output_path(workspace_root, root),
1954
+ "entries": entries,
1955
+ "file_count": len(entries),
1956
+ "truncated": False,
1957
+ }
1958
+ entries = []
1959
+ truncated = False
1960
+ for path in _iter_workspace_entries(root, max_depth=max_depth):
1961
+ if len(entries) >= limit:
1962
+ truncated = True
1963
+ break
1964
+ entries.append(_file_entry(workspace_root, path))
1965
+ return {
1966
+ "root": _workspace_output_path(workspace_root, root),
1967
+ "entries": entries,
1968
+ "file_count": len(entries),
1969
+ "truncated": truncated,
1970
+ }
1971
+
1972
+
1973
+ def _workspace_write(
1974
+ input_payload: Dict[str, Any],
1975
+ *,
1976
+ runtime_workspace_dir: str = "",
1977
+ ) -> Dict[str, Any]:
1978
+ kind = input_payload.get("kind")
1979
+ relative_path = input_payload.get("path")
1980
+ content = input_payload.get("content")
1981
+ metadata = input_payload.get("metadata", {})
1982
+ if not isinstance(kind, str):
1983
+ raise ValueError("kind must be a string")
1984
+ if not isinstance(relative_path, str) or not relative_path.strip():
1985
+ raise ValueError("path must be a non-empty string")
1986
+ if not isinstance(content, str):
1987
+ raise ValueError("content must be a string")
1988
+ if not isinstance(metadata, dict):
1989
+ raise ValueError("metadata must be an object")
1990
+ return _runtime_workspace(runtime_workspace_dir).write_text(
1991
+ kind,
1992
+ relative_path.strip(),
1993
+ content,
1994
+ metadata=metadata,
1995
+ )
1996
+
1997
+
1998
+ def _workspace_read(
1999
+ input_payload: Dict[str, Any],
2000
+ *,
2001
+ runtime_workspace_dir: str = "",
2002
+ ) -> Dict[str, Any]:
2003
+ kind = input_payload.get("kind")
2004
+ relative_path = input_payload.get("path")
2005
+ if not isinstance(kind, str):
2006
+ raise ValueError("kind must be a string")
2007
+ if not isinstance(relative_path, str) or not relative_path.strip():
2008
+ raise ValueError("path must be a non-empty string")
2009
+ return _runtime_workspace(runtime_workspace_dir).read_text(
2010
+ kind,
2011
+ relative_path.strip(),
2012
+ max_bytes=int(input_payload.get("max_bytes", _READ_FILE_MAX_BYTES)),
2013
+ )
2014
+
2015
+
2016
+ def _workspace_list(
2017
+ input_payload: Dict[str, Any],
2018
+ *,
2019
+ runtime_workspace_dir: str = "",
2020
+ ) -> Dict[str, Any]:
2021
+ kind = input_payload.get("kind")
2022
+ relative_path = input_payload.get("path", ".")
2023
+ if not isinstance(kind, str):
2024
+ raise ValueError("kind must be a string")
2025
+ if not isinstance(relative_path, str) or not relative_path.strip():
2026
+ raise ValueError("path must be a non-empty string")
2027
+ return _runtime_workspace(runtime_workspace_dir).list(
2028
+ kind,
2029
+ relative_path.strip(),
2030
+ max_depth=int(input_payload.get("max_depth", _LIST_FILES_MAX_DEPTH)),
2031
+ limit=int(input_payload.get("limit", _LIST_FILES_MAX_ENTRIES)),
2032
+ )
2033
+
2034
+
2035
+ def _workspace_history(
2036
+ input_payload: Dict[str, Any],
2037
+ *,
2038
+ runtime_workspace_dir: str = "",
2039
+ ) -> Dict[str, Any]:
2040
+ kind = input_payload.get("kind")
2041
+ relative_path = input_payload.get("path")
2042
+ if not isinstance(kind, str):
2043
+ raise ValueError("kind must be a string")
2044
+ if not isinstance(relative_path, str) or not relative_path.strip():
2045
+ raise ValueError("path must be a non-empty string")
2046
+ return _runtime_workspace(runtime_workspace_dir).history(
2047
+ kind,
2048
+ relative_path.strip(),
2049
+ limit=int(input_payload.get("limit", 20)),
2050
+ max_bytes=int(input_payload.get("max_bytes", _READ_FILE_MAX_BYTES)),
2051
+ )
2052
+
2053
+
2054
+ def _workspace_diff(
2055
+ input_payload: Dict[str, Any],
2056
+ *,
2057
+ runtime_workspace_dir: str = "",
2058
+ ) -> Dict[str, Any]:
2059
+ kind = input_payload.get("kind")
2060
+ relative_path = input_payload.get("path")
2061
+ revision_id = input_payload.get("revision_id", "")
2062
+ if not isinstance(kind, str):
2063
+ raise ValueError("kind must be a string")
2064
+ if not isinstance(relative_path, str) or not relative_path.strip():
2065
+ raise ValueError("path must be a non-empty string")
2066
+ if not isinstance(revision_id, str):
2067
+ raise ValueError("revision_id must be a string")
2068
+ return _runtime_workspace(runtime_workspace_dir).diff(
2069
+ kind,
2070
+ relative_path.strip(),
2071
+ revision_id=revision_id.strip(),
2072
+ context_lines=int(input_payload.get("context_lines", 3)),
2073
+ max_bytes=int(input_payload.get("max_bytes", _READ_FILE_MAX_BYTES)),
2074
+ )
2075
+
2076
+
2077
+ def _workspace_restore(
2078
+ input_payload: Dict[str, Any],
2079
+ *,
2080
+ runtime_workspace_dir: str = "",
2081
+ ) -> Dict[str, Any]:
2082
+ kind = input_payload.get("kind")
2083
+ relative_path = input_payload.get("path")
2084
+ revision_id = input_payload.get("revision_id")
2085
+ expected_current_sha256 = input_payload.get("expected_current_sha256")
2086
+ expected_revision_sha256 = input_payload.get("expected_revision_sha256")
2087
+ if not isinstance(kind, str):
2088
+ raise ValueError("kind must be a string")
2089
+ if not isinstance(relative_path, str) or not relative_path.strip():
2090
+ raise ValueError("path must be a non-empty string")
2091
+ if not isinstance(revision_id, str) or not revision_id.strip():
2092
+ raise ValueError("revision_id must be a non-empty string")
2093
+ if not isinstance(expected_current_sha256, str):
2094
+ raise ValueError("expected_current_sha256 must be a string")
2095
+ if not isinstance(expected_revision_sha256, str):
2096
+ raise ValueError("expected_revision_sha256 must be a string")
2097
+ return _runtime_workspace(runtime_workspace_dir).restore(
2098
+ kind,
2099
+ relative_path.strip(),
2100
+ revision_id=revision_id.strip(),
2101
+ expected_current_sha256=expected_current_sha256.strip(),
2102
+ expected_revision_sha256=expected_revision_sha256.strip(),
2103
+ )
2104
+
2105
+
2106
+ def _workspace_search(
2107
+ input_payload: Dict[str, Any],
2108
+ *,
2109
+ runtime_workspace_dir: str = "",
2110
+ ) -> Dict[str, Any]:
2111
+ kind = input_payload.get("kind")
2112
+ query = input_payload.get("query")
2113
+ relative_path = input_payload.get("path", ".")
2114
+ if not isinstance(kind, str):
2115
+ raise ValueError("kind must be a string")
2116
+ if not isinstance(query, str) or not query:
2117
+ raise ValueError("query must be a non-empty string")
2118
+ if not isinstance(relative_path, str) or not relative_path.strip():
2119
+ raise ValueError("path must be a non-empty string")
2120
+ return _runtime_workspace(runtime_workspace_dir).search(
2121
+ kind,
2122
+ query,
2123
+ relative_path.strip(),
2124
+ max_depth=int(input_payload.get("max_depth", _LIST_FILES_MAX_DEPTH)),
2125
+ limit=int(input_payload.get("limit", 50)),
2126
+ max_bytes=int(input_payload.get("max_bytes", _READ_FILE_MAX_BYTES)),
2127
+ )
2128
+
2129
+
2130
+ def _runtime_workspace(runtime_workspace_dir: str = "") -> RuntimeWorkspace:
2131
+ if runtime_workspace_dir.strip():
2132
+ return RuntimeWorkspace(runtime_workspace_dir.strip())
2133
+ for env_var in _RUNTIME_WORKSPACE_DIR_ENV_VARS:
2134
+ configured = os.environ.get(env_var, "").strip()
2135
+ if configured:
2136
+ return RuntimeWorkspace(configured)
2137
+ return RuntimeWorkspace(Path.cwd() / ".kagent" / "runtime-workspace")
2138
+
2139
+
2140
+ def _skill_list(
2141
+ _input_payload: Dict[str, Any],
2142
+ *,
2143
+ runtime_skills_dir: str = "",
2144
+ ) -> Dict[str, Any]:
2145
+ skills = _runtime_skill_registry(runtime_skills_dir).list_skills()
2146
+ return {"skills": skills, "skill_count": len(skills)}
2147
+
2148
+
2149
+ def _skill_get(
2150
+ input_payload: Dict[str, Any],
2151
+ *,
2152
+ runtime_skills_dir: str = "",
2153
+ ) -> Dict[str, Any]:
2154
+ name = input_payload.get("name")
2155
+ if not isinstance(name, str) or not name.strip():
2156
+ raise ValueError("name must be a non-empty string")
2157
+ return _runtime_skill_registry(runtime_skills_dir).get_skill(name.strip())
2158
+
2159
+
2160
+ def _runtime_skill_registry(runtime_skills_dir: str = "") -> RuntimeSkillRegistry:
2161
+ if runtime_skills_dir.strip():
2162
+ return RuntimeSkillRegistry(runtime_skills_dir.strip())
2163
+ for env_var in _RUNTIME_SKILLS_DIR_ENV_VARS:
2164
+ configured = os.environ.get(env_var, "").strip()
2165
+ if configured:
2166
+ return RuntimeSkillRegistry(configured)
2167
+ return RuntimeSkillRegistry(Path.cwd() / ".kagent" / "skills")
2168
+
2169
+
2170
+ def _memory_put(
2171
+ input_payload: Dict[str, Any],
2172
+ *,
2173
+ redis_url: str,
2174
+ timeout_seconds: float,
2175
+ ) -> Dict[str, Any]:
2176
+ if not redis_url:
2177
+ raise ValueError("redis memory backend is not configured")
2178
+ _reject_secret_like_memory_payload(input_payload.get("value"))
2179
+ ttl = input_payload.get("ttl_seconds", 0)
2180
+ if isinstance(ttl, bool) or not isinstance(ttl, (int, float)):
2181
+ raise ValueError("ttl_seconds must be a number")
2182
+ if int(ttl) != ttl:
2183
+ raise ValueError("ttl_seconds must be an integer")
2184
+ return RedisShortTermMemory(
2185
+ redis_url,
2186
+ timeout_seconds=timeout_seconds,
2187
+ ).put(
2188
+ namespace=str(input_payload.get("namespace", "")),
2189
+ key=str(input_payload.get("key", "")),
2190
+ value=input_payload.get("value"),
2191
+ ttl_seconds=int(ttl),
2192
+ )
2193
+
2194
+
2195
+ def _memory_get(
2196
+ input_payload: Dict[str, Any],
2197
+ *,
2198
+ redis_url: str,
2199
+ timeout_seconds: float,
2200
+ ) -> Dict[str, Any]:
2201
+ if not redis_url:
2202
+ raise ValueError("redis memory backend is not configured")
2203
+ return RedisShortTermMemory(
2204
+ redis_url,
2205
+ timeout_seconds=timeout_seconds,
2206
+ ).get(
2207
+ namespace=str(input_payload.get("namespace", "")),
2208
+ key=str(input_payload.get("key", "")),
2209
+ )
2210
+
2211
+
2212
+ def _memory_upsert(
2213
+ input_payload: Dict[str, Any],
2214
+ *,
2215
+ milvus_url: str,
2216
+ timeout_seconds: float,
2217
+ ) -> Dict[str, Any]:
2218
+ if not milvus_url:
2219
+ raise ValueError("milvus memory backend is not configured")
2220
+ metadata = input_payload.get("metadata", {})
2221
+ if not isinstance(metadata, dict):
2222
+ raise ValueError("metadata must be an object")
2223
+ _reject_secret_like_memory_payload(
2224
+ {
2225
+ "text": input_payload.get("text", ""),
2226
+ "metadata": metadata,
2227
+ }
2228
+ )
2229
+ return MilvusLongTermMemory(
2230
+ milvus_url,
2231
+ timeout_seconds=timeout_seconds,
2232
+ ).upsert(
2233
+ collection=str(input_payload.get("collection", "")),
2234
+ memory_id=str(input_payload.get("memory_id", "")),
2235
+ text=str(input_payload.get("text", "")),
2236
+ vector=input_payload.get("vector", []),
2237
+ metadata=metadata,
2238
+ )
2239
+
2240
+
2241
+ def _memory_search(
2242
+ input_payload: Dict[str, Any],
2243
+ *,
2244
+ milvus_url: str,
2245
+ timeout_seconds: float,
2246
+ ) -> Dict[str, Any]:
2247
+ if not milvus_url:
2248
+ raise ValueError("milvus memory backend is not configured")
2249
+ limit = input_payload.get("limit", 5)
2250
+ if isinstance(limit, bool) or not isinstance(limit, (int, float)):
2251
+ raise ValueError("limit must be a number")
2252
+ if int(limit) != limit:
2253
+ raise ValueError("limit must be an integer")
2254
+ return MilvusLongTermMemory(
2255
+ milvus_url,
2256
+ timeout_seconds=timeout_seconds,
2257
+ ).search(
2258
+ collection=str(input_payload.get("collection", "")),
2259
+ vector=input_payload.get("vector", []),
2260
+ limit=int(limit),
2261
+ )
2262
+
2263
+
2264
+ def _memory_remember(
2265
+ input_payload: Dict[str, Any],
2266
+ *,
2267
+ milvus_url: str,
2268
+ embedding_config: EmbeddingProviderConfig,
2269
+ timeout_seconds: float,
2270
+ ) -> Dict[str, Any]:
2271
+ if not milvus_url:
2272
+ raise ValueError("milvus memory backend is not configured")
2273
+ _require_embedding_config(embedding_config)
2274
+ metadata = input_payload.get("metadata", {})
2275
+ if not isinstance(metadata, dict):
2276
+ raise ValueError("metadata must be an object")
2277
+ text = str(input_payload.get("text", ""))
2278
+ _reject_secret_like_memory_payload({"text": text, "metadata": metadata})
2279
+ vector = OpenAICompatibleEmbeddingProvider(embedding_config).embed(text)
2280
+ output = MilvusLongTermMemory(
2281
+ milvus_url,
2282
+ timeout_seconds=timeout_seconds,
2283
+ ).upsert(
2284
+ collection=str(input_payload.get("collection", "")),
2285
+ memory_id=str(input_payload.get("memory_id", "")),
2286
+ text=text,
2287
+ vector=vector,
2288
+ metadata=metadata,
2289
+ )
2290
+ return {
2291
+ **output,
2292
+ "embedding_model": embedding_config.model,
2293
+ "vector_dimensions": str(len(vector)),
2294
+ }
2295
+
2296
+
2297
+ def _memory_recall(
2298
+ input_payload: Dict[str, Any],
2299
+ *,
2300
+ milvus_url: str,
2301
+ embedding_config: EmbeddingProviderConfig,
2302
+ timeout_seconds: float,
2303
+ ) -> Dict[str, Any]:
2304
+ if not milvus_url:
2305
+ raise ValueError("milvus memory backend is not configured")
2306
+ _require_embedding_config(embedding_config)
2307
+ limit = input_payload.get("limit", 5)
2308
+ if isinstance(limit, bool) or not isinstance(limit, (int, float)):
2309
+ raise ValueError("limit must be a number")
2310
+ if int(limit) != limit:
2311
+ raise ValueError("limit must be an integer")
2312
+ vector = OpenAICompatibleEmbeddingProvider(embedding_config).embed(
2313
+ str(input_payload.get("query", ""))
2314
+ )
2315
+ output = MilvusLongTermMemory(
2316
+ milvus_url,
2317
+ timeout_seconds=timeout_seconds,
2318
+ ).search(
2319
+ collection=str(input_payload.get("collection", "")),
2320
+ vector=vector,
2321
+ limit=int(limit),
2322
+ )
2323
+ return {
2324
+ **output,
2325
+ "embedding_model": embedding_config.model,
2326
+ "vector_dimensions": str(len(vector)),
2327
+ }
2328
+
2329
+
2330
+ def _require_embedding_config(config: EmbeddingProviderConfig) -> None:
2331
+ if not config.base_url:
2332
+ raise ValueError("embedding base_url is not configured")
2333
+ if not config.model:
2334
+ raise ValueError("embedding model is not configured")
2335
+
2336
+
2337
+ def _reject_secret_like_memory_payload(value: Any) -> None:
2338
+ if redact_runtime_payload(value) != value:
2339
+ raise ValueError("memory write payload contains secret-like text")
2340
+
2341
+
2342
+ def _first_env_value(names: tuple[str, ...]) -> str:
2343
+ for name in names:
2344
+ value = os.environ.get(name, "").strip()
2345
+ if value:
2346
+ return value
2347
+ return ""
2348
+
2349
+
2350
+ def _embedding_config(
2351
+ *,
2352
+ base_url: str,
2353
+ api_key: str,
2354
+ model: str,
2355
+ timeout_seconds: float,
2356
+ max_retries: int,
2357
+ retry_backoff_seconds: float,
2358
+ ) -> EmbeddingProviderConfig:
2359
+ return EmbeddingProviderConfig(
2360
+ base_url=base_url.strip() or _first_env_value(_EMBEDDING_BASE_URL_ENV_VARS),
2361
+ api_key=api_key.strip() or _first_env_value(_EMBEDDING_API_KEY_ENV_VARS),
2362
+ model=model.strip() or _first_env_value(_EMBEDDING_MODEL_ENV_VARS),
2363
+ timeout_seconds=_embedding_timeout_seconds(timeout_seconds),
2364
+ max_retries=_embedding_max_retries(max_retries),
2365
+ retry_backoff_seconds=_embedding_retry_backoff_seconds(
2366
+ retry_backoff_seconds
2367
+ ),
2368
+ )
2369
+
2370
+
2371
+ def _embedding_timeout_seconds(default_value: float) -> float:
2372
+ env_value = os.environ.get("KAGENT_EMBEDDING_TIMEOUT_SECONDS", "").strip()
2373
+ value = env_value or str(default_value)
2374
+ try:
2375
+ timeout = float(value)
2376
+ except ValueError as exc:
2377
+ raise ValueError("embedding timeout must be a float") from exc
2378
+ if timeout <= 0:
2379
+ raise ValueError("embedding timeout must be positive")
2380
+ return timeout
2381
+
2382
+
2383
+ def _embedding_max_retries(default_value: int) -> int:
2384
+ env_value = os.environ.get(_EMBEDDING_MAX_RETRIES_ENV_VAR, "").strip()
2385
+ value = env_value or str(default_value)
2386
+ try:
2387
+ retries = int(value)
2388
+ except ValueError as exc:
2389
+ raise ValueError("embedding max_retries must be an integer") from exc
2390
+ if retries < 0:
2391
+ raise ValueError("embedding max_retries must be non-negative")
2392
+ return retries
2393
+
2394
+
2395
+ def _embedding_retry_backoff_seconds(default_value: float) -> float:
2396
+ env_value = os.environ.get(_EMBEDDING_RETRY_BACKOFF_ENV_VAR, "").strip()
2397
+ value = env_value or str(default_value)
2398
+ try:
2399
+ backoff = float(value)
2400
+ except ValueError as exc:
2401
+ raise ValueError("embedding retry_backoff_seconds must be a float") from exc
2402
+ if backoff < 0:
2403
+ raise ValueError("embedding retry_backoff_seconds must be non-negative")
2404
+ return backoff
2405
+
2406
+
2407
+ def _backend_timeout_seconds(default_value: float) -> float:
2408
+ env_value = os.environ.get(_EXTERNAL_BACKEND_TIMEOUT_ENV_VAR, "").strip()
2409
+ value = env_value or str(default_value)
2410
+ try:
2411
+ timeout = float(value)
2412
+ except ValueError as exc:
2413
+ raise ValueError("external backend timeout must be a float") from exc
2414
+ if timeout <= 0:
2415
+ raise ValueError("external backend timeout must be positive")
2416
+ return timeout
2417
+
2418
+
2419
+ def _shell_command(input_payload: Dict[str, Any]) -> Dict[str, Any]:
2420
+ command = input_payload.get("command")
2421
+ if not isinstance(command, str) or not command.strip():
2422
+ raise ValueError("command must be a non-empty string")
2423
+ normalized_command = command.strip()
2424
+ _validate_shell_command(normalized_command)
2425
+
2426
+ timeout_seconds = float(
2427
+ input_payload.get("timeout_seconds", _SHELL_COMMAND_DEFAULT_TIMEOUT_SECONDS)
2428
+ )
2429
+ max_output_bytes = int(
2430
+ input_payload.get("max_output_bytes", _SHELL_COMMAND_MAX_OUTPUT_BYTES)
2431
+ )
2432
+ workspace_root = Path.cwd().resolve()
2433
+ cwd = _resolve_shell_cwd(workspace_root, input_payload.get("cwd", "."))
2434
+ sandbox_env = _shell_sandbox_env(workspace_root, cwd)
2435
+ sandbox = _shell_sandbox_metadata()
2436
+ started = time.perf_counter()
2437
+ timed_out = False
2438
+ try:
2439
+ sandbox_result = run_shell_sandboxed(
2440
+ normalized_command,
2441
+ workspace_root=workspace_root,
2442
+ cwd=cwd,
2443
+ env=sandbox_env,
2444
+ timeout_seconds=timeout_seconds,
2445
+ )
2446
+ sandbox = sandbox_result.metadata
2447
+ completed = sandbox_result.completed
2448
+ exit_code = completed.returncode
2449
+ stdout = completed.stdout or b""
2450
+ stderr = completed.stderr or b""
2451
+ except subprocess.TimeoutExpired as exc:
2452
+ timed_out = True
2453
+ exit_code = -1
2454
+ stdout = exc.stdout or b""
2455
+ stderr = exc.stderr or b""
2456
+ duration_seconds = time.perf_counter() - started
2457
+ stdout, stderr, truncated = _truncate_shell_output(
2458
+ stdout,
2459
+ stderr,
2460
+ max_output_bytes=max_output_bytes,
2461
+ )
2462
+ return {
2463
+ "command": normalized_command,
2464
+ "cwd": _workspace_output_path(workspace_root, cwd),
2465
+ "sandbox": sandbox,
2466
+ "exit_code": exit_code,
2467
+ "stdout": stdout.decode("utf-8", errors="replace"),
2468
+ "stderr": stderr.decode("utf-8", errors="replace"),
2469
+ "duration_seconds": float(f"{duration_seconds:.4f}"),
2470
+ "timed_out": timed_out,
2471
+ "truncated": truncated,
2472
+ }
2473
+
2474
+
2475
+ def _validate_shell_command(command: str) -> None:
2476
+ if "\x00" in command or "\n" in command or "\r" in command:
2477
+ raise ValueError("command must be a single line")
2478
+ if _SHELL_BACKGROUND_PATTERN.search(command):
2479
+ raise ValueError("background shell commands are not supported")
2480
+ for pattern in _SHELL_INTERACTIVE_PATTERNS:
2481
+ if pattern.search(command):
2482
+ raise ValueError("interactive shell commands are not supported")
2483
+ for pattern in _SHELL_DESTRUCTIVE_PATTERNS:
2484
+ if pattern.search(command):
2485
+ raise ValueError("high-risk shell commands are not supported")
2486
+ for pattern in _SHELL_SECRET_EXPOSURE_PATTERNS:
2487
+ if pattern.search(command):
2488
+ raise ValueError("secret-exposing shell commands are not supported")
2489
+ if _SHELL_PIPE_TO_SHELL_PATTERN.search(command):
2490
+ raise ValueError("pipe-to-shell commands are not supported")
2491
+ for pattern in _SHELL_NETWORK_COMMAND_PATTERNS:
2492
+ if pattern.search(command):
2493
+ raise ValueError("network shell commands are not supported; use http_request")
2494
+ for pattern in _SHELL_INLINE_NETWORK_CODE_PATTERNS:
2495
+ if pattern.search(command):
2496
+ raise ValueError("network shell commands are not supported; use http_request")
2497
+
2498
+
2499
+ def _shell_sandbox_metadata() -> Dict[str, str]:
2500
+ return {
2501
+ "enabled": "true",
2502
+ "backend": "soft",
2503
+ "enforced": "false",
2504
+ "filesystem": "workspace",
2505
+ "network": "disabled",
2506
+ "env_policy": "minimal",
2507
+ }
2508
+
2509
+
2510
+ def _shell_sandbox_env(workspace_root: Path, cwd: Path) -> Dict[str, str]:
2511
+ return {
2512
+ "HOME": str(workspace_root),
2513
+ "LANG": "C.UTF-8",
2514
+ "LC_ALL": "C.UTF-8",
2515
+ "PATH": os.environ.get("PATH", os.defpath),
2516
+ "PWD": str(cwd),
2517
+ "KAGENT_RUNTIME_SANDBOX": "1",
2518
+ "KAGENT_SANDBOX_FILESYSTEM": "workspace",
2519
+ "KAGENT_SANDBOX_NETWORK": "disabled",
2520
+ }
2521
+
2522
+
2523
+ def _resolve_shell_cwd(workspace_root: Path, relative_path: Any) -> Path:
2524
+ if relative_path in {"", "."}:
2525
+ return workspace_root
2526
+ if not isinstance(relative_path, str) or not relative_path.strip():
2527
+ raise ValueError("cwd must be a string")
2528
+ cwd = _resolve_workspace_relative_path(workspace_root, relative_path.strip())
2529
+ if not cwd.exists():
2530
+ raise ValueError(f"cwd does not exist: {relative_path}")
2531
+ if not cwd.is_dir():
2532
+ raise ValueError(f"cwd is not a directory: {relative_path}")
2533
+ return cwd
2534
+
2535
+
2536
+ def _truncate_shell_output(
2537
+ stdout: bytes,
2538
+ stderr: bytes,
2539
+ *,
2540
+ max_output_bytes: int,
2541
+ ) -> tuple[bytes, bytes, bool]:
2542
+ remaining = max_output_bytes
2543
+ truncated = False
2544
+ if len(stdout) > remaining:
2545
+ stdout = stdout[:remaining]
2546
+ remaining = 0
2547
+ truncated = True
2548
+ else:
2549
+ remaining -= len(stdout)
2550
+ if len(stderr) > remaining:
2551
+ stderr = stderr[:remaining]
2552
+ truncated = True
2553
+ return stdout, stderr, truncated
2554
+
2555
+
2556
+ def _apply_patch(input_payload: Dict[str, Any]) -> Dict[str, Any]:
2557
+ patch = input_payload.get("patch")
2558
+ if not isinstance(patch, str) or not patch.strip():
2559
+ raise ValueError("patch must be a non-empty string")
2560
+ operations = _parse_workspace_patch(patch)
2561
+ workspace_root = Path.cwd().resolve()
2562
+ store = PatchCheckpointStore.from_environment()
2563
+ store.recover_prepared(workspace_root)
2564
+ with workspace_transaction(workspace_root):
2565
+ return _stage_and_commit_workspace_patch(workspace_root, operations, store)
2566
+
2567
+
2568
+ def _stage_and_commit_workspace_patch(
2569
+ workspace_root: Path,
2570
+ operations: list[_PatchOperation],
2571
+ store: PatchCheckpointStore,
2572
+ ) -> Dict[str, Any]:
2573
+ staged_contents: dict[Path, str | None] = {}
2574
+ changed_files = []
2575
+
2576
+ for operation in operations:
2577
+ target = _resolve_workspace_relative_path(workspace_root, operation.relative_path)
2578
+ current_content = _staged_or_disk_content(target, staged_contents)
2579
+ if operation.operation == "add":
2580
+ if current_content is not None:
2581
+ raise ValueError(f"file already exists: {operation.relative_path}")
2582
+ next_content: str | None = operation.content
2583
+ elif operation.operation == "update":
2584
+ if current_content is None:
2585
+ raise ValueError(f"file does not exist: {operation.relative_path}")
2586
+ next_content = _apply_update_lines(
2587
+ current_content,
2588
+ operation.lines,
2589
+ operation.relative_path,
2590
+ )
2591
+ elif operation.operation == "delete":
2592
+ if current_content is None:
2593
+ raise ValueError(f"file does not exist: {operation.relative_path}")
2594
+ if target.is_dir():
2595
+ raise ValueError(f"path is a directory: {operation.relative_path}")
2596
+ next_content = None
2597
+ elif operation.operation == "move":
2598
+ if current_content is None:
2599
+ raise ValueError(f"file does not exist: {operation.relative_path}")
2600
+ if target.is_dir():
2601
+ raise ValueError(f"path is a directory: {operation.relative_path}")
2602
+ destination = _resolve_workspace_relative_path(
2603
+ workspace_root,
2604
+ operation.destination_path,
2605
+ )
2606
+ if destination == target:
2607
+ raise ValueError("move destination must differ from source")
2608
+ if _staged_or_disk_content(destination, staged_contents) is not None:
2609
+ raise ValueError(f"file already exists: {operation.destination_path}")
2610
+ next_content = current_content
2611
+ if operation.lines:
2612
+ next_content = _apply_update_lines(
2613
+ current_content,
2614
+ operation.lines,
2615
+ operation.relative_path,
2616
+ )
2617
+ staged_contents[target] = None
2618
+ staged_contents[destination] = next_content
2619
+ encoded = next_content.encode("utf-8")
2620
+ changed_files.append(
2621
+ {
2622
+ "path": operation.destination_path,
2623
+ "previous_path": operation.relative_path,
2624
+ "operation": operation.operation,
2625
+ "bytes": len(encoded),
2626
+ "sha256": hashlib.sha256(encoded).hexdigest(),
2627
+ }
2628
+ )
2629
+ continue
2630
+ else: # pragma: no cover - parser owns the operation enum
2631
+ raise ValueError(f"unsupported patch operation: {operation.operation}")
2632
+
2633
+ staged_contents[target] = next_content
2634
+ encoded = (next_content or "").encode("utf-8")
2635
+ changed_files.append(
2636
+ {
2637
+ "path": operation.relative_path,
2638
+ "operation": operation.operation,
2639
+ "bytes": len(encoded),
2640
+ "sha256": hashlib.sha256(encoded).hexdigest(),
2641
+ }
2642
+ )
2643
+
2644
+ commit_checkpointed_text_changes(
2645
+ store,
2646
+ workspace_root,
2647
+ staged_contents,
2648
+ )
2649
+ return {"changed_files": changed_files, "file_count": len(changed_files)}
2650
+
2651
+
2652
+ def _patch_history(input_payload: Dict[str, Any]) -> Dict[str, Any]:
2653
+ limit = int(input_payload.get("limit", 20))
2654
+ workspace_root = Path.cwd().resolve()
2655
+ store = PatchCheckpointStore.from_environment()
2656
+ store.recover_prepared(workspace_root)
2657
+ return store.history(workspace_root, limit=limit)
2658
+
2659
+
2660
+ def _revert_patch(input_payload: Dict[str, Any]) -> Dict[str, Any]:
2661
+ checkpoint_id = input_payload.get("checkpoint_id")
2662
+ paths = input_payload.get("paths")
2663
+ if not isinstance(checkpoint_id, str) or not checkpoint_id.strip():
2664
+ raise ValueError("checkpoint_id must be a non-empty string")
2665
+ if not isinstance(paths, list) or not all(
2666
+ isinstance(path, str) and path for path in paths
2667
+ ):
2668
+ raise ValueError("paths must be a non-empty string array")
2669
+ workspace_root = Path.cwd().resolve()
2670
+ store = PatchCheckpointStore.from_environment()
2671
+ store.recover_prepared(workspace_root)
2672
+ with workspace_transaction(workspace_root):
2673
+ revert = store.load_revert(workspace_root, checkpoint_id.strip())
2674
+ checkpoint_paths = [
2675
+ target.relative_to(workspace_root).as_posix()
2676
+ for target in revert.staged_contents
2677
+ ]
2678
+ if paths != checkpoint_paths:
2679
+ raise ValueError("paths do not match the reviewed checkpoint")
2680
+ for target in revert.staged_contents:
2681
+ _reject_symlink_path_parts(
2682
+ workspace_root,
2683
+ target.relative_to(workspace_root),
2684
+ )
2685
+ current_states = capture_text_states(revert.expected_current)
2686
+ for target, expected_content in revert.expected_current.items():
2687
+ if current_states[target].content != expected_content:
2688
+ relative_path = target.relative_to(workspace_root).as_posix()
2689
+ raise ValueError(
2690
+ f"current SHA-256 does not match checkpoint: {relative_path}"
2691
+ )
2692
+ new_checkpoint_id = commit_checkpointed_text_changes(
2693
+ store,
2694
+ workspace_root,
2695
+ revert.staged_contents,
2696
+ target_modes=revert.target_modes,
2697
+ )
2698
+ changed_files = []
2699
+ for target, content in revert.staged_contents.items():
2700
+ previous_content = revert.expected_current[target]
2701
+ if previous_content is None:
2702
+ operation = "add"
2703
+ elif content is None:
2704
+ operation = "delete"
2705
+ else:
2706
+ operation = "update"
2707
+ encoded = (content or "").encode("utf-8")
2708
+ changed_files.append(
2709
+ {
2710
+ "path": target.relative_to(workspace_root).as_posix(),
2711
+ "operation": operation,
2712
+ "bytes": len(encoded),
2713
+ "sha256": hashlib.sha256(encoded).hexdigest(),
2714
+ }
2715
+ )
2716
+ return {
2717
+ "checkpoint_id": new_checkpoint_id,
2718
+ "reverted_checkpoint_id": checkpoint_id.strip(),
2719
+ "changed_files": changed_files,
2720
+ "file_count": len(changed_files),
2721
+ }
2722
+
2723
+
2724
+ def _parse_workspace_patch(patch: str) -> list[_PatchOperation]:
2725
+ lines = patch.splitlines()
2726
+ if not lines or lines[0] != "*** Begin Patch":
2727
+ raise ValueError("patch must start with *** Begin Patch")
2728
+ if lines[-1] != "*** End Patch":
2729
+ raise ValueError("patch must end with *** End Patch")
2730
+ operations = []
2731
+ index = 1
2732
+ while index < len(lines) - 1:
2733
+ line = lines[index]
2734
+ if line.startswith("*** Add File: "):
2735
+ relative_path = line.removeprefix("*** Add File: ").strip()
2736
+ if not relative_path:
2737
+ raise ValueError("patch file path is required")
2738
+ index += 1
2739
+ content_lines = []
2740
+ while index < len(lines) - 1 and not lines[index].startswith("*** "):
2741
+ content_line = lines[index]
2742
+ content_lines.append(
2743
+ content_line[1:] if content_line.startswith("+") else content_line
2744
+ )
2745
+ index += 1
2746
+ if not content_lines:
2747
+ raise ValueError("add file patch must contain at least one content line")
2748
+ operations.append(
2749
+ _PatchOperation(
2750
+ operation="add",
2751
+ relative_path=relative_path,
2752
+ content="\n".join(content_lines) + "\n",
2753
+ )
2754
+ )
2755
+ continue
2756
+ if line.startswith("*** Update File: "):
2757
+ relative_path = line.removeprefix("*** Update File: ").strip()
2758
+ if not relative_path:
2759
+ raise ValueError("patch file path is required")
2760
+ index += 1
2761
+ destination_path = ""
2762
+ if index < len(lines) - 1 and lines[index].startswith("*** Move to: "):
2763
+ destination_path = lines[index].removeprefix("*** Move to: ").strip()
2764
+ if not destination_path:
2765
+ raise ValueError("move destination path is required")
2766
+ index += 1
2767
+ update_lines = []
2768
+ while index < len(lines) - 1 and not lines[index].startswith("*** "):
2769
+ update_line = lines[index]
2770
+ if update_line == "@@":
2771
+ index += 1
2772
+ continue
2773
+ update_lines.append(update_line)
2774
+ index += 1
2775
+ if not update_lines and not destination_path:
2776
+ raise ValueError("update file patch must contain at least one change line")
2777
+ if destination_path:
2778
+ operations.append(
2779
+ _PatchOperation(
2780
+ operation="move",
2781
+ relative_path=relative_path,
2782
+ destination_path=destination_path,
2783
+ lines=tuple(update_lines),
2784
+ )
2785
+ )
2786
+ continue
2787
+ operations.append(
2788
+ _PatchOperation(
2789
+ operation="update",
2790
+ relative_path=relative_path,
2791
+ lines=tuple(update_lines),
2792
+ )
2793
+ )
2794
+ continue
2795
+ if line.startswith("*** Delete File: "):
2796
+ relative_path = line.removeprefix("*** Delete File: ").strip()
2797
+ if not relative_path:
2798
+ raise ValueError("patch file path is required")
2799
+ index += 1
2800
+ operations.append(
2801
+ _PatchOperation(operation="delete", relative_path=relative_path)
2802
+ )
2803
+ continue
2804
+ raise ValueError(
2805
+ "only Add File, Update File, Move to, and Delete File patch hunks are supported"
2806
+ )
2807
+ if not operations:
2808
+ raise ValueError("patch must contain at least one file hunk")
2809
+ return operations
2810
+
2811
+
2812
+ def _staged_or_disk_content(
2813
+ target: Path,
2814
+ staged_contents: dict[Path, str | None],
2815
+ ) -> str | None:
2816
+ if target in staged_contents:
2817
+ return staged_contents[target]
2818
+ if not target.exists():
2819
+ return None
2820
+ if target.is_dir():
2821
+ raise ValueError("path is a directory")
2822
+ return target.read_text(encoding="utf-8")
2823
+
2824
+
2825
+ def _apply_update_lines(
2826
+ content: str,
2827
+ update_lines: tuple[str, ...],
2828
+ relative_path: str,
2829
+ ) -> str:
2830
+ old_lines = []
2831
+ new_lines = []
2832
+ for line in update_lines:
2833
+ if line.startswith(" "):
2834
+ value = line[1:]
2835
+ old_lines.append(value)
2836
+ new_lines.append(value)
2837
+ continue
2838
+ if line.startswith("-"):
2839
+ old_lines.append(line[1:])
2840
+ continue
2841
+ if line.startswith("+"):
2842
+ new_lines.append(line[1:])
2843
+ continue
2844
+ raise ValueError("update lines must start with space, -, +, or @@")
2845
+ if old_lines == new_lines:
2846
+ raise ValueError("update file patch must change file content")
2847
+ content_lines = content.splitlines()
2848
+ start = _find_subsequence(content_lines, old_lines)
2849
+ if start is None:
2850
+ raise ValueError(f"update context not found: {relative_path}")
2851
+ next_lines = content_lines[:start] + new_lines + content_lines[start + len(old_lines) :]
2852
+ return "\n".join(next_lines) + "\n"
2853
+
2854
+
2855
+ def _find_subsequence(lines: list[str], needle: list[str]) -> int | None:
2856
+ if not needle:
2857
+ return None
2858
+ last_start = len(lines) - len(needle)
2859
+ for index in range(last_start + 1):
2860
+ if lines[index : index + len(needle)] == needle:
2861
+ return index
2862
+ return None
2863
+
2864
+
2865
+ def _iter_workspace_entries(root: Path, *, max_depth: int):
2866
+ root_depth = len(root.parts)
2867
+ for path in sorted(root.rglob("*"), key=lambda item: item.relative_to(root).as_posix()):
2868
+ if path.is_symlink():
2869
+ continue
2870
+ depth = len(path.parts) - root_depth
2871
+ if depth > max_depth:
2872
+ continue
2873
+ yield path
2874
+
2875
+
2876
+ def _file_entry(workspace_root: Path, path: Path) -> Dict[str, Any]:
2877
+ if path.is_dir():
2878
+ return {
2879
+ "path": _workspace_output_path(workspace_root, path),
2880
+ "type": "directory",
2881
+ "bytes": 0,
2882
+ }
2883
+ return {
2884
+ "path": _workspace_output_path(workspace_root, path),
2885
+ "type": "file",
2886
+ "bytes": path.stat().st_size,
2887
+ }
2888
+
2889
+
2890
+ def _workspace_output_path(workspace_root: Path, path: Path) -> str:
2891
+ try:
2892
+ relative = path.relative_to(workspace_root)
2893
+ except ValueError as exc:
2894
+ raise ValueError("path must stay inside the workspace") from exc
2895
+ output = relative.as_posix()
2896
+ return "." if output == "" else output
2897
+
2898
+
2899
+ def _resolve_workspace_relative_path(workspace_root: Path, relative_path: str) -> Path:
2900
+ candidate_path = Path(relative_path)
2901
+ if candidate_path.is_absolute():
2902
+ raise ValueError("path must stay inside the workspace")
2903
+ if any(part in {"", ".", ".."} for part in candidate_path.parts):
2904
+ raise ValueError("path must stay inside the workspace")
2905
+ _reject_symlink_path_parts(workspace_root, candidate_path)
2906
+ normalized_path = os.path.normpath(relative_path)
2907
+ target = (workspace_root / normalized_path).resolve()
2908
+ try:
2909
+ target.relative_to(workspace_root)
2910
+ except ValueError as exc:
2911
+ raise ValueError("path must stay inside the workspace") from exc
2912
+ return target
2913
+
2914
+
2915
+ def _reject_symlink_path_parts(workspace_root: Path, candidate_path: Path) -> None:
2916
+ current = workspace_root
2917
+ for part in candidate_path.parts:
2918
+ current = current / part
2919
+ if current.is_symlink():
2920
+ raise ValueError("path must not be a symlink")
2921
+ if not current.exists():
2922
+ return
2923
+
2924
+
2925
+ def _http_request(input_payload: Dict[str, Any]) -> Dict[str, Any]:
2926
+ url = input_payload.get("url")
2927
+ if not isinstance(url, str) or not url.strip():
2928
+ raise ValueError("url must be a non-empty string")
2929
+ normalized_url = url.strip()
2930
+ _validate_http_request_url(normalized_url)
2931
+ max_bytes = int(input_payload.get("max_bytes", _HTTP_REQUEST_MAX_BYTES))
2932
+ request = urllib.request.Request(
2933
+ normalized_url,
2934
+ headers={"User-Agent": "kagent/0.1"},
2935
+ method="GET",
2936
+ )
2937
+ try:
2938
+ with _open_http_request_without_redirects(
2939
+ request,
2940
+ timeout_seconds=_HTTP_REQUEST_TIMEOUT_SECONDS,
2941
+ ) as response:
2942
+ body = response.read(max_bytes + 1)
2943
+ content_type = response.headers.get("Content-Type", "")
2944
+ status_code = int(response.status)
2945
+ except urllib.error.HTTPError as exc:
2946
+ body = exc.read(max_bytes + 1)
2947
+ content_type = exc.headers.get("Content-Type", "")
2948
+ status_code = int(exc.code)
2949
+ except urllib.error.URLError as exc:
2950
+ raise ValueError("http request failed") from exc
2951
+ truncated = len(body) > max_bytes
2952
+ if truncated:
2953
+ body = body[:max_bytes]
2954
+ return {
2955
+ "url": normalized_url,
2956
+ "status_code": status_code,
2957
+ "content_type": content_type,
2958
+ "body_text": body.decode("utf-8", errors="replace"),
2959
+ "bytes": len(body),
2960
+ "truncated": truncated,
2961
+ }
2962
+
2963
+
2964
+ def _open_url(input_payload: Dict[str, Any]) -> Dict[str, Any]:
2965
+ url = input_payload.get("url")
2966
+ if not isinstance(url, str) or not url.strip():
2967
+ raise ValueError("url must be a non-empty string")
2968
+ normalized_url = url.strip()
2969
+ _validate_open_url(normalized_url)
2970
+ attempts = [
2971
+ (
2972
+ ["osascript", "-e", _chrome_open_location_script(normalized_url)],
2973
+ "Google Chrome",
2974
+ "osascript Google Chrome",
2975
+ ),
2976
+ (["open", "-a", "Google Chrome", normalized_url], "Google Chrome", "open -a Google Chrome"),
2977
+ (["open", normalized_url], "default", "open"),
2978
+ ]
2979
+ last_error: BaseException | None = None
2980
+ for command_args, application, command_label in attempts:
2981
+ try:
2982
+ subprocess.run(
2983
+ command_args,
2984
+ check=True,
2985
+ capture_output=True,
2986
+ text=True,
2987
+ timeout=_OPEN_COMMAND_TIMEOUT_SECONDS,
2988
+ )
2989
+ return {
2990
+ "url": normalized_url,
2991
+ "opened": True,
2992
+ "application": application,
2993
+ "command": command_label,
2994
+ }
2995
+ except subprocess.TimeoutExpired as exc:
2996
+ raise TimeoutError("open url command timed out") from exc
2997
+ except (OSError, subprocess.CalledProcessError) as exc:
2998
+ last_error = exc
2999
+ continue
3000
+ raise ValueError("open url failed") from last_error
3001
+
3002
+
3003
+ def _open_app(input_payload: Dict[str, Any]) -> Dict[str, Any]:
3004
+ application = input_payload.get("application")
3005
+ if not isinstance(application, str) or not application.strip():
3006
+ raise ValueError("application must be a non-empty string")
3007
+ normalized_application = " ".join(application.strip().split())
3008
+ _validate_open_app_name(normalized_application)
3009
+ try:
3010
+ subprocess.run(
3011
+ ["open", "-a", normalized_application],
3012
+ check=True,
3013
+ capture_output=True,
3014
+ text=True,
3015
+ timeout=_OPEN_COMMAND_TIMEOUT_SECONDS,
3016
+ )
3017
+ except subprocess.TimeoutExpired as exc:
3018
+ raise TimeoutError("open app command timed out") from exc
3019
+ except (OSError, subprocess.CalledProcessError) as exc:
3020
+ raise ValueError("open app failed") from exc
3021
+ return {
3022
+ "application": normalized_application,
3023
+ "opened": True,
3024
+ "command": "open -a",
3025
+ }
3026
+
3027
+
3028
+ def _validate_open_app_name(application: str) -> None:
3029
+ if len(application) > _APP_NAME_MAX_LENGTH:
3030
+ raise ValueError("application name is too long")
3031
+ if "\x00" in application or "\n" in application or "\r" in application:
3032
+ raise ValueError("application must be a single line")
3033
+ if application.startswith("-"):
3034
+ raise ValueError("application must not start with -")
3035
+ if "/" in application or "\\" in application:
3036
+ raise ValueError("application must be an app name, not a path")
3037
+ if not _APP_NAME_ALLOWED_PATTERN.match(application):
3038
+ raise ValueError("application contains unsupported characters")
3039
+
3040
+
3041
+ def _validate_open_url(url: str) -> None:
3042
+ parsed = urllib.parse.urlparse(url)
3043
+ if parsed.scheme not in {"http", "https"}:
3044
+ raise ValueError("url must start with http:// or https://")
3045
+ if not parsed.hostname:
3046
+ raise ValueError("url host is required")
3047
+ if parsed.username or parsed.password:
3048
+ raise ValueError("url must not contain credentials")
3049
+ _validate_url_without_secret_like_query_or_fragment(parsed)
3050
+
3051
+
3052
+ def _chrome_open_location_script(url: str) -> str:
3053
+ escaped_url = _apple_script_string(url)
3054
+ return (
3055
+ 'tell application "Google Chrome"\n'
3056
+ " activate\n"
3057
+ " if (count of windows) = 0 then make new window\n"
3058
+ " tell front window\n"
3059
+ f" make new tab at end of tabs with properties {{URL:{escaped_url}}}\n"
3060
+ " set active tab index to (count of tabs)\n"
3061
+ " end tell\n"
3062
+ "end tell"
3063
+ )
3064
+
3065
+
3066
+ def _apple_script_string(value: str) -> str:
3067
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
3068
+
3069
+
3070
+ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
3071
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
3072
+ return None
3073
+
3074
+
3075
+ def _open_http_request_without_redirects(
3076
+ request: urllib.request.Request,
3077
+ *,
3078
+ timeout_seconds: float,
3079
+ ):
3080
+ opener = urllib.request.build_opener(_NoRedirectHandler)
3081
+ try:
3082
+ return opener.open(request, timeout=timeout_seconds)
3083
+ except urllib.error.HTTPError as exc:
3084
+ if exc.code in _HTTP_REDIRECT_STATUS_CODES:
3085
+ return exc
3086
+ raise
3087
+
3088
+
3089
+ def _validate_http_request_url(url: str) -> None:
3090
+ parsed = urllib.parse.urlparse(url)
3091
+ if parsed.scheme not in {"http", "https"}:
3092
+ raise ValueError("url must start with http:// or https://")
3093
+ if not parsed.hostname:
3094
+ raise ValueError("url host is required")
3095
+ if parsed.username or parsed.password:
3096
+ raise ValueError("url must not contain credentials")
3097
+ _validate_url_without_secret_like_query_or_fragment(parsed)
3098
+ host = parsed.hostname.strip().lower()
3099
+ if host in _BLOCKED_HTTP_HOSTS or host.endswith(".localhost"):
3100
+ raise ValueError("url host is not allowed")
3101
+ try:
3102
+ address = ipaddress.ip_address(host)
3103
+ except ValueError:
3104
+ _validate_resolved_http_host(host, parsed.port)
3105
+ return
3106
+ if _is_blocked_http_address(address):
3107
+ raise ValueError("url host is not allowed")
3108
+
3109
+
3110
+ def _validate_resolved_http_host(host: str, port: int | None) -> None:
3111
+ try:
3112
+ records = socket.getaddrinfo(host, port or 443, type=socket.SOCK_STREAM)
3113
+ except socket.gaierror as exc:
3114
+ raise ValueError("url host could not be resolved") from exc
3115
+ for record in records:
3116
+ sockaddr = record[4]
3117
+ if not sockaddr:
3118
+ continue
3119
+ try:
3120
+ address = ipaddress.ip_address(sockaddr[0])
3121
+ except ValueError as exc:
3122
+ raise ValueError("url host resolved to an invalid address") from exc
3123
+ if _is_blocked_http_address(address):
3124
+ raise ValueError("url host is not allowed")
3125
+
3126
+
3127
+ def _validate_url_without_secret_like_query_or_fragment(
3128
+ parsed: urllib.parse.ParseResult,
3129
+ ) -> None:
3130
+ for item in (parsed.query, parsed.fragment):
3131
+ if _has_secret_like_url_part(item):
3132
+ raise ValueError("url must not contain secret-like query or fragment")
3133
+
3134
+
3135
+ def _has_secret_like_url_part(value: str) -> bool:
3136
+ if not value:
3137
+ return False
3138
+ pairs = urllib.parse.parse_qsl(value, keep_blank_values=True)
3139
+ if not pairs:
3140
+ pairs = [(value, "")]
3141
+ for key, item in pairs:
3142
+ normalized_key = key.lower().replace("-", "_")
3143
+ if any(part in normalized_key for part in _URL_SECRET_KEY_PARTS):
3144
+ return True
3145
+ if any(pattern.search(item) for pattern in _URL_SECRET_VALUE_PATTERNS):
3146
+ return True
3147
+ return False
3148
+
3149
+
3150
+ def _is_blocked_http_address(address: ipaddress._BaseAddress) -> bool:
3151
+ return (
3152
+ address.is_loopback
3153
+ or address.is_private
3154
+ or address.is_link_local
3155
+ or address.is_multicast
3156
+ or address.is_reserved
3157
+ or address.is_unspecified
3158
+ )
3159
+
3160
+
3161
+ def _artifact(input_payload: Dict[str, Any]) -> Dict[str, Any]:
3162
+ title = input_payload.get("title")
3163
+ kind = input_payload.get("kind")
3164
+ content = input_payload.get("content")
3165
+ artifact_format = input_payload.get("format", "markdown")
3166
+ tags = input_payload.get("tags", [])
3167
+
3168
+ if not isinstance(title, str) or not title.strip():
3169
+ raise ValueError("title must be a non-empty string")
3170
+ if kind not in _ARTIFACT_KINDS:
3171
+ raise ValueError("kind must be report, plan, decision, data, or message")
3172
+ if not isinstance(content, str) or not content.strip():
3173
+ raise ValueError("content must be a non-empty string")
3174
+ if artifact_format not in _ARTIFACT_FORMATS:
3175
+ raise ValueError("format must be markdown, plain_text, or json")
3176
+ if not isinstance(tags, list):
3177
+ raise ValueError("tags must be an array")
3178
+
3179
+ normalized_title = title.strip()
3180
+ normalized_tags = []
3181
+ for index, tag in enumerate(tags):
3182
+ if not isinstance(tag, str):
3183
+ raise ValueError(f"tag {index} must be a string")
3184
+ normalized_tag = tag.strip()
3185
+ if normalized_tag:
3186
+ normalized_tags.append(normalized_tag)
3187
+
3188
+ artifact_id = _artifact_id(normalized_title, str(kind), str(artifact_format), content)
3189
+ return {
3190
+ "artifact_id": artifact_id,
3191
+ "title": normalized_title,
3192
+ "kind": kind,
3193
+ "format": artifact_format,
3194
+ "content": content,
3195
+ "tags": normalized_tags,
3196
+ "bytes": len(content.encode("utf-8")),
3197
+ }
3198
+
3199
+
3200
+ def _artifact_id(title: str, kind: str, artifact_format: str, content: str) -> str:
3201
+ digest = hashlib.sha256(
3202
+ "|".join([title, kind, artifact_format, content]).encode("utf-8")
3203
+ ).hexdigest()
3204
+ return f"artifact_{digest[:12]}"
3205
+
3206
+
3207
+ def _decision_matrix(input_payload: Dict[str, Any]) -> Dict[str, Any]:
3208
+ question = input_payload.get("question")
3209
+ criteria = input_payload.get("criteria")
3210
+ options = input_payload.get("options")
3211
+
3212
+ if not isinstance(question, str) or not question.strip():
3213
+ raise ValueError("question must be a non-empty string")
3214
+ if not isinstance(criteria, list) or not criteria:
3215
+ raise ValueError("criteria must be a non-empty array")
3216
+ if not isinstance(options, list) or len(options) < 2:
3217
+ raise ValueError("options must contain at least 2 item(s)")
3218
+
3219
+ normalized_criteria = _normalize_decision_criteria(criteria)
3220
+ rankings = _rank_decision_options(options, normalized_criteria)
3221
+ return {
3222
+ "question": question.strip(),
3223
+ "criteria": normalized_criteria,
3224
+ "rankings": rankings,
3225
+ "winner": rankings[0]["name"],
3226
+ }
3227
+
3228
+
3229
+ def _normalize_decision_criteria(criteria: list[Any]) -> list[Dict[str, Any]]:
3230
+ normalized = []
3231
+ for index, criterion in enumerate(criteria):
3232
+ if not isinstance(criterion, dict):
3233
+ raise ValueError(f"criterion {index} must be an object")
3234
+ name = criterion.get("name")
3235
+ weight = criterion.get("weight")
3236
+ if not isinstance(name, str) or not name.strip():
3237
+ raise ValueError(f"criterion {index} name must be a non-empty string")
3238
+ if not _is_number(weight):
3239
+ raise ValueError(f"criterion {index} weight must be a number")
3240
+ normalized.append({"name": name.strip(), "weight": float(weight)})
3241
+ return normalized
3242
+
3243
+
3244
+ def _rank_decision_options(
3245
+ options: list[Any],
3246
+ criteria: list[Dict[str, Any]],
3247
+ ) -> list[Dict[str, Any]]:
3248
+ scored_options = []
3249
+ for index, option in enumerate(options):
3250
+ if not isinstance(option, dict):
3251
+ raise ValueError(f"option {index} must be an object")
3252
+ name = option.get("name")
3253
+ scores = option.get("scores")
3254
+ rationale = option.get("rationale", "")
3255
+ if not isinstance(name, str) or not name.strip():
3256
+ raise ValueError(f"option {index} name must be a non-empty string")
3257
+ if not isinstance(scores, list) or len(scores) != len(criteria):
3258
+ raise ValueError(
3259
+ f"option {index} scores must match the number of criteria"
3260
+ )
3261
+ if not isinstance(rationale, str):
3262
+ raise ValueError(f"option {index} rationale must be a string")
3263
+ normalized_scores = []
3264
+ total = 0.0
3265
+ for score_index, score in enumerate(scores):
3266
+ if not _is_number(score):
3267
+ raise ValueError(f"option {index} score {score_index} must be a number")
3268
+ normalized_score = float(score)
3269
+ normalized_scores.append(normalized_score)
3270
+ total += normalized_score * float(criteria[score_index]["weight"])
3271
+ scored_options.append(
3272
+ {
3273
+ "name": name.strip(),
3274
+ "score": round(total, 4),
3275
+ "scores": normalized_scores,
3276
+ "rationale": rationale.strip(),
3277
+ }
3278
+ )
3279
+
3280
+ ranked = sorted(scored_options, key=lambda item: (-item["score"], item["name"]))
3281
+ for index, option in enumerate(ranked, start=1):
3282
+ option["rank"] = index
3283
+ return [
3284
+ {
3285
+ "rank": option["rank"],
3286
+ "name": option["name"],
3287
+ "score": option["score"],
3288
+ "scores": option["scores"],
3289
+ "rationale": option["rationale"],
3290
+ }
3291
+ for option in ranked
3292
+ ]
3293
+
3294
+
3295
+ def _validate_tool_input(value: Any, schema: Dict[str, Any], path: str = "input") -> None:
3296
+ expected_type = schema.get("type")
3297
+ if expected_type == "object":
3298
+ _validate_object(value, schema, path)
3299
+ elif expected_type == "array":
3300
+ _validate_array(value, schema, path)
3301
+ elif expected_type == "string":
3302
+ if not isinstance(value, str):
3303
+ raise ValueError(f"{path} must be a string")
3304
+ min_length = schema.get("minLength")
3305
+ if isinstance(min_length, int) and len(value.strip()) < min_length:
3306
+ raise ValueError(
3307
+ f"{path} must contain at least {min_length} character(s)"
3308
+ )
3309
+ max_length = schema.get("maxLength")
3310
+ if isinstance(max_length, int) and len(value) > max_length:
3311
+ raise ValueError(f"{path} must contain at most {max_length} character(s)")
3312
+ elif expected_type == "number":
3313
+ if not _is_number(value):
3314
+ raise ValueError(f"{path} must be a number")
3315
+ minimum = schema.get("minimum")
3316
+ if _is_number(minimum) and value < minimum:
3317
+ raise ValueError(f"{path} must be at least {minimum:g}")
3318
+ maximum = schema.get("maximum")
3319
+ if _is_number(maximum) and value > maximum:
3320
+ raise ValueError(f"{path} must be at most {maximum:g}")
3321
+ elif expected_type == "boolean":
3322
+ if not isinstance(value, bool):
3323
+ raise ValueError(f"{path} must be a boolean")
3324
+
3325
+ enum_values = schema.get("enum")
3326
+ if isinstance(enum_values, list) and value not in enum_values:
3327
+ allowed = ", ".join(str(item) for item in enum_values)
3328
+ raise ValueError(f"{path} must be one of: {allowed}")
3329
+
3330
+
3331
+ def _validate_object(value: Any, schema: Dict[str, Any], path: str) -> None:
3332
+ if not isinstance(value, dict):
3333
+ raise ValueError(f"{path} must be an object")
3334
+ properties = schema.get("properties", {})
3335
+ if not isinstance(properties, dict):
3336
+ properties = {}
3337
+ required = schema.get("required", [])
3338
+ if not isinstance(required, list):
3339
+ required = []
3340
+ for key in required:
3341
+ if key not in value:
3342
+ raise ValueError(f"{path}.{key} is required")
3343
+ if schema.get("additionalProperties") is False:
3344
+ for key in value:
3345
+ if key not in properties:
3346
+ raise ValueError(f"{path}.{key} is not allowed")
3347
+ for key, property_schema in properties.items():
3348
+ if key in value and isinstance(property_schema, dict):
3349
+ _validate_tool_input(value[key], property_schema, f"{path}.{key}")
3350
+
3351
+
3352
+ def _validate_array(value: Any, schema: Dict[str, Any], path: str) -> None:
3353
+ if not isinstance(value, list):
3354
+ raise ValueError(f"{path} must be an array")
3355
+ min_items = schema.get("minItems")
3356
+ if isinstance(min_items, int) and len(value) < min_items:
3357
+ raise ValueError(f"{path} must contain at least {min_items} item(s)")
3358
+ max_items = schema.get("maxItems")
3359
+ if isinstance(max_items, int) and len(value) > max_items:
3360
+ raise ValueError(f"{path} must contain at most {max_items} item(s)")
3361
+ item_schema = schema.get("items")
3362
+ if isinstance(item_schema, dict):
3363
+ for index, item in enumerate(value):
3364
+ _validate_tool_input(item, item_schema, f"{path}[{index}]")
3365
+
3366
+
3367
+ def _is_number(value: Any) -> bool:
3368
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
3369
+
3370
+
3371
+ def _utc_timestamp() -> str:
3372
+ return datetime.now(timezone.utc).isoformat()
3373
+
3374
+
3375
+ def _duration_since(started_at: float) -> str:
3376
+ return f"{time.perf_counter() - started_at:.4f}"
3377
+
3378
+
3379
+ def _delegate_task(
3380
+ input_payload: Dict[str, Any],
3381
+ *,
3382
+ delegate_runner: Callable[[str, int], Dict[str, Any]] | None,
3383
+ ) -> Dict[str, Any]:
3384
+ if delegate_runner is None:
3385
+ raise ValueError("delegate runner is not configured")
3386
+ goal = input_payload.get("goal")
3387
+ if not isinstance(goal, str) or not goal.strip():
3388
+ raise ValueError("goal must be a non-empty string")
3389
+ max_iterations = int(input_payload.get("max_iterations", 1))
3390
+ if max_iterations < 1 or max_iterations > _DELEGATE_MAX_ITERATIONS:
3391
+ raise ValueError(
3392
+ f"max_iterations must be between 1 and {_DELEGATE_MAX_ITERATIONS}"
3393
+ )
3394
+ child = delegate_runner(goal.strip(), max_iterations)
3395
+ observations = child.get("observations", [])
3396
+ if not isinstance(observations, list):
3397
+ observations = []
3398
+ return {
3399
+ "child_run_id": str(child.get("run_id", "")),
3400
+ "status": str(child.get("status", "")),
3401
+ "answer": str(child.get("answer", "")),
3402
+ "error_code": str(child.get("error_code", "")),
3403
+ "child_iteration_count": str(child.get("iteration_count", "")),
3404
+ "child_observation_count": str(len(observations)),
3405
+ }
3406
+
3407
+
3408
+ def _transform_text(input_payload: Dict[str, Any]) -> Dict[str, Any]:
3409
+ text = input_payload.get("text")
3410
+ mode = input_payload.get("mode")
3411
+ if not isinstance(text, str):
3412
+ raise ValueError("text must be a string")
3413
+ if mode == "uppercase":
3414
+ return {"text": text.upper()}
3415
+ if mode == "lowercase":
3416
+ return {"text": text.lower()}
3417
+ if mode == "reverse":
3418
+ return {"text": text[::-1]}
3419
+ if mode == "trim":
3420
+ return {"text": text.strip()}
3421
+ raise ValueError("mode must be uppercase, lowercase, reverse, or trim")
3422
+
3423
+
3424
+ def _rubric_score(input_payload: Dict[str, Any]) -> Dict[str, Any]:
3425
+ criteria = input_payload.get("criteria")
3426
+ if not isinstance(criteria, list) or not criteria:
3427
+ raise ValueError("criteria must be a non-empty array")
3428
+
3429
+ normalized_criteria = []
3430
+ failed_criteria = []
3431
+ blocking_failures = []
3432
+ passed_count = 0
3433
+ for index, criterion in enumerate(criteria):
3434
+ if not isinstance(criterion, dict):
3435
+ raise ValueError(f"criterion {index} must be an object")
3436
+ name = criterion.get("name")
3437
+ passed = criterion.get("passed")
3438
+ severity = criterion.get("severity", "normal")
3439
+ evidence = criterion.get("evidence", "")
3440
+ if not isinstance(name, str) or not name.strip():
3441
+ raise ValueError(f"criterion {index} name must be a non-empty string")
3442
+ if not isinstance(passed, bool):
3443
+ raise ValueError(f"criterion {index} passed must be a boolean")
3444
+ if severity not in _RUBRIC_SEVERITIES:
3445
+ raise ValueError(f"criterion {index} severity must be low, normal, or blocking")
3446
+ if not isinstance(evidence, str):
3447
+ raise ValueError(f"criterion {index} evidence must be a string")
3448
+
3449
+ normalized_name = name.strip()
3450
+ normalized = {
3451
+ "name": normalized_name,
3452
+ "passed": passed,
3453
+ "severity": severity,
3454
+ "evidence": evidence.strip(),
3455
+ }
3456
+ normalized_criteria.append(normalized)
3457
+ if passed:
3458
+ passed_count += 1
3459
+ continue
3460
+ failed_criteria.append(normalized_name)
3461
+ if severity == "blocking":
3462
+ blocking_failures.append(normalized_name)
3463
+
3464
+ total = len(normalized_criteria)
3465
+ failed_count = total - passed_count
3466
+ return {
3467
+ "criteria": normalized_criteria,
3468
+ "passed": passed_count,
3469
+ "failed": failed_count,
3470
+ "total": total,
3471
+ "score_percent": round((passed_count / total) * 100, 2),
3472
+ "blocking_failures": blocking_failures,
3473
+ "failed_criteria": failed_criteria,
3474
+ }
3475
+
3476
+
3477
+ def _task_list(input_payload: Dict[str, Any]) -> Dict[str, Any]:
3478
+ items = input_payload.get("items")
3479
+ if not isinstance(items, list) or not items:
3480
+ raise ValueError("items must be a non-empty array")
3481
+
3482
+ normalized_items = []
3483
+ counts = {status: 0 for status in _TASK_STATUSES}
3484
+ for index, item in enumerate(items):
3485
+ if not isinstance(item, dict):
3486
+ raise ValueError(f"item {index} must be an object")
3487
+ normalized = _normalize_task_item(item, index)
3488
+ normalized_items.append(normalized)
3489
+ counts[normalized["status"]] += 1
3490
+ return {
3491
+ "items": normalized_items,
3492
+ "counts": counts,
3493
+ "total": len(normalized_items),
3494
+ }
3495
+
3496
+
3497
+ def _task_transition(input_payload: Dict[str, Any]) -> Dict[str, Any]:
3498
+ state = input_payload.get("state")
3499
+ event = input_payload.get("event")
3500
+ if not isinstance(state, str):
3501
+ raise ValueError("state must be a string")
3502
+ if not isinstance(event, str):
3503
+ raise ValueError("event must be a string")
3504
+ return TaskStateMachine().transition(state, event)
3505
+
3506
+
3507
+ def _normalize_task_item(item: Dict[str, Any], index: int) -> Dict[str, Any]:
3508
+ title = item.get("title")
3509
+ if not isinstance(title, str) or not title.strip():
3510
+ raise ValueError(f"item {index} title must be a non-empty string")
3511
+ status = item.get("status", "pending")
3512
+ if status not in _TASK_STATUSES:
3513
+ raise ValueError(
3514
+ f"item {index} status must be pending, in_progress, blocked, done, or failed"
3515
+ )
3516
+ priority = item.get("priority", "normal")
3517
+ if priority not in _TASK_PRIORITIES:
3518
+ raise ValueError(f"item {index} priority must be low, normal, or high")
3519
+
3520
+ normalized = {
3521
+ "title": title.strip(),
3522
+ "status": status,
3523
+ "priority": priority,
3524
+ }
3525
+ for optional_field in ["owner", "due"]:
3526
+ value = item.get(optional_field, "")
3527
+ if value == "":
3528
+ continue
3529
+ if not isinstance(value, str):
3530
+ raise ValueError(f"item {index} {optional_field} must be a string")
3531
+ normalized[optional_field] = value
3532
+ return normalized