@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.
- package/README.md +353 -0
- package/npm/bin/kagent-serve.js +6 -0
- package/npm/bin/kagent.js +6 -0
- package/npm/lib/App.js +524 -0
- package/npm/lib/app-state.js +224 -0
- package/npm/lib/approval-choice.js +25 -0
- package/npm/lib/commands.js +59 -0
- package/npm/lib/editor.js +188 -0
- package/npm/lib/ink-runner.js +41 -0
- package/npm/lib/kagent-home.js +39 -0
- package/npm/lib/launcher.js +221 -0
- package/npm/lib/protocol.js +33 -0
- package/npm/lib/provider-setup.js +139 -0
- package/npm/lib/python-runner.js +892 -0
- package/npm/lib/runtime-client.js +390 -0
- package/npm/lib/terminal-input.js +127 -0
- package/npm/lib/terminal-text.js +19 -0
- package/npm/lib/terminal-width.js +14 -0
- package/npm/lib/transcript.js +227 -0
- package/npm/lib/ui-components.js +247 -0
- package/npm/lib/update-manager.js +334 -0
- package/package.json +39 -0
- package/pyproject.toml +55 -0
- package/src/kagent/__init__.py +90 -0
- package/src/kagent/cli/__init__.py +5 -0
- package/src/kagent/cli/__main__.py +6 -0
- package/src/kagent/cli/commands.py +112 -0
- package/src/kagent/cli/conversation.py +127 -0
- package/src/kagent/cli/interactive.py +841 -0
- package/src/kagent/cli/main.py +685 -0
- package/src/kagent/cli/memory.py +460 -0
- package/src/kagent/cli/pending_approval.py +169 -0
- package/src/kagent/cli/provider.py +27 -0
- package/src/kagent/cli/session_commands.py +401 -0
- package/src/kagent/cli/stdio_runtime.py +784 -0
- package/src/kagent/cli/trace.py +67 -0
- package/src/kagent/cli/ui.py +931 -0
- package/src/kagent/core/__init__.py +0 -0
- package/src/kagent/core/agent.py +296 -0
- package/src/kagent/core/faults.py +11 -0
- package/src/kagent/core/invariants.py +47 -0
- package/src/kagent/core/normalization.py +81 -0
- package/src/kagent/core/planning.py +48 -0
- package/src/kagent/core/state.py +73 -0
- package/src/kagent/core/summary.py +64 -0
- package/src/kagent/core/tools.py +196 -0
- package/src/kagent/core/trace.py +57 -0
- package/src/kagent/eval/__init__.py +11 -0
- package/src/kagent/eval/cases.py +229 -0
- package/src/kagent/eval/evaluator.py +216 -0
- package/src/kagent/integrations/__init__.py +3 -0
- package/src/kagent/integrations/audit.py +95 -0
- package/src/kagent/integrations/backends.py +131 -0
- package/src/kagent/integrations/memory.py +301 -0
- package/src/kagent/ops/__init__.py +0 -0
- package/src/kagent/ops/batch.py +156 -0
- package/src/kagent/ops/doctor.py +255 -0
- package/src/kagent/ops/metrics.py +214 -0
- package/src/kagent/ops/release_evidence.py +877 -0
- package/src/kagent/ops/release_manifest.py +142 -0
- package/src/kagent/ops/trace_replay.py +285 -0
- package/src/kagent/providers/__init__.py +7 -0
- package/src/kagent/providers/embeddings.py +187 -0
- package/src/kagent/providers/llm.py +770 -0
- package/src/kagent/py.typed +1 -0
- package/src/kagent/runtime/__init__.py +28 -0
- package/src/kagent/runtime/action_graph.py +543 -0
- package/src/kagent/runtime/agent.py +2089 -0
- package/src/kagent/runtime/approval.py +64 -0
- package/src/kagent/runtime/cancellation.py +64 -0
- package/src/kagent/runtime/checkpoint_state.py +146 -0
- package/src/kagent/runtime/checkpoint_storage.py +270 -0
- package/src/kagent/runtime/context.py +65 -0
- package/src/kagent/runtime/file_transaction.py +195 -0
- package/src/kagent/runtime/hooks.py +74 -0
- package/src/kagent/runtime/metadata.py +116 -0
- package/src/kagent/runtime/patch_checkpoints.py +385 -0
- package/src/kagent/runtime/policy.py +50 -0
- package/src/kagent/runtime/presentation.py +205 -0
- package/src/kagent/runtime/redaction.py +130 -0
- package/src/kagent/runtime/sandbox.py +331 -0
- package/src/kagent/runtime/skills.py +66 -0
- package/src/kagent/runtime/steering.py +56 -0
- package/src/kagent/runtime/steps.py +255 -0
- package/src/kagent/runtime/task_state.py +40 -0
- package/src/kagent/runtime/tools.py +3532 -0
- package/src/kagent/runtime/types.py +240 -0
- package/src/kagent/runtime/workspace.py +597 -0
- package/src/kagent/service/__init__.py +26 -0
- package/src/kagent/service/__main__.py +6 -0
- package/src/kagent/service/active_runs.py +178 -0
- package/src/kagent/service/cli.py +737 -0
- package/src/kagent/service/contract.py +2571 -0
- package/src/kagent/service/errors.py +71 -0
- package/src/kagent/service/idempotency.py +584 -0
- package/src/kagent/service/router.py +884 -0
- package/src/kagent/service/run.py +150 -0
- package/src/kagent/service/runtime.py +1915 -0
- package/src/kagent/service/runtime_approval.py +20 -0
- package/src/kagent/service/runtime_cancel.py +192 -0
- package/src/kagent/service/runtime_lifecycle.py +229 -0
- package/src/kagent/service/runtime_metadata.py +21 -0
- package/src/kagent/service/runtime_policy.py +115 -0
- package/src/kagent/service/runtime_recovery.py +573 -0
- package/src/kagent/service/runtime_resume.py +382 -0
- package/src/kagent/service/runtime_resume_claim.py +116 -0
- package/src/kagent/service/runtime_run.py +350 -0
- package/src/kagent/service/runtime_status.py +2007 -0
- package/src/kagent/service/safety.py +139 -0
- package/src/kagent/service/server.py +114 -0
- package/src/kagent/service/status.py +233 -0
- package/src/kagent/service/trace_store.py +322 -0
- package/src/kagent/service/transport.py +24 -0
- package/src/kagent/utils/__init__.py +0 -0
- package/src/kagent/utils/config_validation.py +21 -0
- package/src/kagent/utils/json_output.py +23 -0
- package/src/kagent/utils/paths.py +473 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import warnings
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Dict, Iterable, TextIO
|
|
9
|
+
|
|
10
|
+
from kagent.cli.conversation import (
|
|
11
|
+
RUNTIME_MEMORY_MAX_TURNS,
|
|
12
|
+
remember_runtime_turn,
|
|
13
|
+
runtime_goal_with_memory,
|
|
14
|
+
)
|
|
15
|
+
from kagent.cli.memory import (
|
|
16
|
+
default_runtime_session_memory_path,
|
|
17
|
+
load_runtime_session_memory,
|
|
18
|
+
redact_runtime_session_memory_text,
|
|
19
|
+
save_runtime_session_memory,
|
|
20
|
+
)
|
|
21
|
+
from kagent.cli.pending_approval import (
|
|
22
|
+
clear_pending_approval,
|
|
23
|
+
default_pending_approval_path,
|
|
24
|
+
load_pending_approval,
|
|
25
|
+
save_pending_approval,
|
|
26
|
+
)
|
|
27
|
+
from kagent.cli.provider import RuntimeProviderConfigError, runtime_provider_config_message
|
|
28
|
+
from kagent.cli.session_commands import (
|
|
29
|
+
SessionCommandError,
|
|
30
|
+
execute_session_command,
|
|
31
|
+
redacted_provider_snapshot,
|
|
32
|
+
runtime_session_command_catalog,
|
|
33
|
+
)
|
|
34
|
+
from kagent.providers.llm import (
|
|
35
|
+
FakeLLMProvider,
|
|
36
|
+
LLMProviderConfig,
|
|
37
|
+
build_llm_provider,
|
|
38
|
+
missing_provider_config_fields,
|
|
39
|
+
provider_setup_options,
|
|
40
|
+
save_provider_config,
|
|
41
|
+
validate_provider_setup_config,
|
|
42
|
+
)
|
|
43
|
+
from kagent.runtime import build_resumable_plan, run_runtime_agent
|
|
44
|
+
from kagent.runtime.cancellation import RuntimeCancellationToken
|
|
45
|
+
from kagent.runtime.steering import RuntimeSteeringBuffer
|
|
46
|
+
from kagent.utils.json_output import json_ready
|
|
47
|
+
|
|
48
|
+
Request = Dict[str, Any]
|
|
49
|
+
DEFAULT_RUNTIME_MAX_ITERATIONS = 3
|
|
50
|
+
_APPROVAL_EXECUTION_INTERRUPTED_MESSAGE = (
|
|
51
|
+
"The approved action was interrupted and was not replayed "
|
|
52
|
+
"because its side-effect state is uncertain."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class PendingApproval:
|
|
58
|
+
action: Dict[str, Any]
|
|
59
|
+
goal: str
|
|
60
|
+
runtime_goal: str
|
|
61
|
+
plan: Dict[str, Any]
|
|
62
|
+
phase: str = "awaiting_approval"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ActiveRun:
|
|
67
|
+
generation: int
|
|
68
|
+
cancellation_token: RuntimeCancellationToken
|
|
69
|
+
steering_buffer: RuntimeSteeringBuffer
|
|
70
|
+
thread: threading.Thread
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class StdioRuntimeSession:
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
stdout: TextIO,
|
|
77
|
+
*,
|
|
78
|
+
memory_path: str | None = None,
|
|
79
|
+
pending_approval_path: str | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.stdout = stdout
|
|
82
|
+
self.memory_path = (
|
|
83
|
+
default_runtime_session_memory_path() if memory_path is None else memory_path
|
|
84
|
+
)
|
|
85
|
+
self.memory = load_runtime_session_memory(
|
|
86
|
+
self.memory_path,
|
|
87
|
+
max_turns=RUNTIME_MEMORY_MAX_TURNS,
|
|
88
|
+
)
|
|
89
|
+
self.provider_config = LLMProviderConfig.from_sources()
|
|
90
|
+
self.pending_approval_path = (
|
|
91
|
+
default_pending_approval_path()
|
|
92
|
+
if pending_approval_path is None
|
|
93
|
+
else pending_approval_path
|
|
94
|
+
)
|
|
95
|
+
persisted_pending = load_pending_approval(self.pending_approval_path)
|
|
96
|
+
self.pending_approval = (
|
|
97
|
+
PendingApproval(**persisted_pending) if persisted_pending else None
|
|
98
|
+
)
|
|
99
|
+
self.last_payload: Dict[str, Any] | None = None
|
|
100
|
+
self.active_run: ActiveRun | None = None
|
|
101
|
+
self._run_generation = 0
|
|
102
|
+
self._state_lock = threading.RLock()
|
|
103
|
+
self._state_changed = threading.Condition(self._state_lock)
|
|
104
|
+
self._stdout_lock = threading.Lock()
|
|
105
|
+
|
|
106
|
+
def handle(self, request: Request) -> None:
|
|
107
|
+
request_type = str(request.get("type", ""))
|
|
108
|
+
if request_type == "run_request":
|
|
109
|
+
self._handle_run_request(request)
|
|
110
|
+
return
|
|
111
|
+
if request_type == "approval_response":
|
|
112
|
+
self._handle_approval_response(request)
|
|
113
|
+
return
|
|
114
|
+
if request_type == "cancel_request":
|
|
115
|
+
self._handle_cancel_request(request)
|
|
116
|
+
return
|
|
117
|
+
if request_type == "steer_request":
|
|
118
|
+
self._handle_steer_request(request)
|
|
119
|
+
return
|
|
120
|
+
if request_type == "provider_configure":
|
|
121
|
+
self._handle_provider_configure(request)
|
|
122
|
+
return
|
|
123
|
+
if request_type == "session_command":
|
|
124
|
+
self._handle_session_command(request)
|
|
125
|
+
return
|
|
126
|
+
self._fail(
|
|
127
|
+
"invalid_request_type",
|
|
128
|
+
"request type must be run_request, approval_response, cancel_request, "
|
|
129
|
+
"steer_request, provider_configure, or session_command",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def ready_event(self) -> Dict[str, Any]:
|
|
133
|
+
pending_phase = self.pending_approval.phase if self.pending_approval else ""
|
|
134
|
+
return {
|
|
135
|
+
"type": "runtime_ready",
|
|
136
|
+
"provider": redacted_provider_snapshot(self.provider_config),
|
|
137
|
+
"provider_options": provider_setup_options(),
|
|
138
|
+
"session_commands": runtime_session_command_catalog(),
|
|
139
|
+
"pending_approval": pending_phase == "awaiting_approval",
|
|
140
|
+
"approval_execution_interrupted": pending_phase == "approved_executing",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def recovered_approval_event(self) -> Dict[str, Any] | None:
|
|
144
|
+
if self.pending_approval is None:
|
|
145
|
+
return None
|
|
146
|
+
if self.pending_approval.phase == "approved_executing":
|
|
147
|
+
self.pending_approval = None
|
|
148
|
+
return {
|
|
149
|
+
"type": "run_failed",
|
|
150
|
+
"error_code": "approval_execution_interrupted",
|
|
151
|
+
"message": _APPROVAL_EXECUTION_INTERRUPTED_MESSAGE,
|
|
152
|
+
}
|
|
153
|
+
return _approval_event(self.pending_approval.action)
|
|
154
|
+
|
|
155
|
+
def _handle_run_request(self, request: Request) -> None:
|
|
156
|
+
with self._state_lock:
|
|
157
|
+
pending_approval = self.pending_approval is not None
|
|
158
|
+
active_run = self.active_run is not None
|
|
159
|
+
if pending_approval:
|
|
160
|
+
self._fail(
|
|
161
|
+
"approval_pending",
|
|
162
|
+
"respond to the pending approval before starting another request",
|
|
163
|
+
)
|
|
164
|
+
return
|
|
165
|
+
if active_run:
|
|
166
|
+
self._fail("runtime_busy", "wait for the active run to finish")
|
|
167
|
+
return
|
|
168
|
+
goal = str(request.get("goal", "")).strip()
|
|
169
|
+
if not goal:
|
|
170
|
+
self._fail("missing_goal", "goal is required")
|
|
171
|
+
return
|
|
172
|
+
try:
|
|
173
|
+
max_iterations = _positive_int(
|
|
174
|
+
request.get("max_iterations"),
|
|
175
|
+
default=DEFAULT_RUNTIME_MAX_ITERATIONS,
|
|
176
|
+
)
|
|
177
|
+
except (TypeError, ValueError) as exc:
|
|
178
|
+
self._fail("invalid_request", str(exc))
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
self._emit(
|
|
182
|
+
{
|
|
183
|
+
"type": "run_started",
|
|
184
|
+
"goal": goal,
|
|
185
|
+
"max_iterations": str(max_iterations),
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
try:
|
|
189
|
+
provider = _provider_from_request(request, self.provider_config)
|
|
190
|
+
runtime_goal = runtime_goal_with_memory(goal, self.memory)
|
|
191
|
+
except RuntimeProviderConfigError as exc:
|
|
192
|
+
self._fail("provider_not_configured", str(exc))
|
|
193
|
+
return
|
|
194
|
+
self._start_active_run(
|
|
195
|
+
goal,
|
|
196
|
+
runtime_goal,
|
|
197
|
+
provider=provider,
|
|
198
|
+
max_iterations=max_iterations,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _handle_cancel_request(self, request: Request) -> None:
|
|
202
|
+
reason = str(request.get("reason", "")).strip() or "user requested cancellation"
|
|
203
|
+
with self._state_lock:
|
|
204
|
+
active_run = self.active_run
|
|
205
|
+
if active_run is None:
|
|
206
|
+
self._fail("no_active_run", "there is no active run to cancel")
|
|
207
|
+
return
|
|
208
|
+
active_run.cancellation_token.cancel(reason)
|
|
209
|
+
snapshot = active_run.cancellation_token.snapshot()
|
|
210
|
+
self._emit(
|
|
211
|
+
{
|
|
212
|
+
"type": "run_cancel_requested",
|
|
213
|
+
"reason": snapshot["reason"] or reason,
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _handle_steer_request(self, request: Request) -> None:
|
|
218
|
+
instruction = str(request.get("instruction", "")).strip()
|
|
219
|
+
if not instruction:
|
|
220
|
+
self._steer_failed("missing_instruction", "steering instruction is required")
|
|
221
|
+
return
|
|
222
|
+
if len(instruction) > 20000:
|
|
223
|
+
self._steer_failed(
|
|
224
|
+
"instruction_too_long",
|
|
225
|
+
"steering instruction must contain at most 20000 characters",
|
|
226
|
+
)
|
|
227
|
+
return
|
|
228
|
+
with self._state_lock:
|
|
229
|
+
active_run = self.active_run
|
|
230
|
+
if active_run is None:
|
|
231
|
+
self._steer_failed("no_active_run", "there is no active run to steer")
|
|
232
|
+
return
|
|
233
|
+
try:
|
|
234
|
+
active_run.steering_buffer.submit(
|
|
235
|
+
instruction,
|
|
236
|
+
accepted=lambda snapshot: self._emit(
|
|
237
|
+
{
|
|
238
|
+
"type": "run_steer_queued",
|
|
239
|
+
"revision": snapshot["revision"],
|
|
240
|
+
"replaced": snapshot["replaced"],
|
|
241
|
+
}
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
except (RuntimeError, ValueError) as exc:
|
|
245
|
+
self._steer_failed("steering_closed", str(exc))
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
def _handle_provider_configure(self, request: Request) -> None:
|
|
249
|
+
with self._state_lock:
|
|
250
|
+
pending_approval = self.pending_approval is not None
|
|
251
|
+
active_run = self.active_run is not None
|
|
252
|
+
if pending_approval:
|
|
253
|
+
self._provider_configuration_failed(
|
|
254
|
+
"approval_pending",
|
|
255
|
+
"respond to the pending approval before changing the provider",
|
|
256
|
+
)
|
|
257
|
+
return
|
|
258
|
+
if active_run:
|
|
259
|
+
self._provider_configuration_failed(
|
|
260
|
+
"runtime_busy",
|
|
261
|
+
"wait for the active run to finish before changing the provider",
|
|
262
|
+
)
|
|
263
|
+
return
|
|
264
|
+
try:
|
|
265
|
+
config = LLMProviderConfig(
|
|
266
|
+
provider=str(request.get("provider", "")),
|
|
267
|
+
base_url=str(request.get("base_url", "")).strip(),
|
|
268
|
+
api_key=str(request.get("api_key", "")).strip(),
|
|
269
|
+
model=str(request.get("model", "")).strip(),
|
|
270
|
+
)
|
|
271
|
+
validate_provider_setup_config(config)
|
|
272
|
+
save_provider_config(config)
|
|
273
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
274
|
+
message = str(exc)
|
|
275
|
+
self._provider_configuration_failed(
|
|
276
|
+
"invalid_provider_config",
|
|
277
|
+
message,
|
|
278
|
+
field=_provider_error_field(message),
|
|
279
|
+
)
|
|
280
|
+
return
|
|
281
|
+
self.provider_config = config
|
|
282
|
+
self._emit(
|
|
283
|
+
{
|
|
284
|
+
"type": "provider_configured",
|
|
285
|
+
"provider": redacted_provider_snapshot(config),
|
|
286
|
+
},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def _handle_session_command(self, request: Request) -> None:
|
|
290
|
+
command = str(request.get("command", "")).strip()
|
|
291
|
+
with self._state_lock:
|
|
292
|
+
pending_approval = self.pending_approval is not None
|
|
293
|
+
active_run = self.active_run is not None
|
|
294
|
+
if pending_approval:
|
|
295
|
+
self._session_command_failed(
|
|
296
|
+
command,
|
|
297
|
+
"approval_pending",
|
|
298
|
+
"Respond to the pending approval before running a command.",
|
|
299
|
+
)
|
|
300
|
+
return
|
|
301
|
+
if active_run:
|
|
302
|
+
self._session_command_failed(
|
|
303
|
+
command,
|
|
304
|
+
"runtime_busy",
|
|
305
|
+
"Wait for the active run to finish before running a command.",
|
|
306
|
+
)
|
|
307
|
+
return
|
|
308
|
+
try:
|
|
309
|
+
result = execute_session_command(
|
|
310
|
+
command,
|
|
311
|
+
memory=self.memory,
|
|
312
|
+
memory_path=self.memory_path,
|
|
313
|
+
provider_config=self.provider_config,
|
|
314
|
+
last_payload=self.last_payload,
|
|
315
|
+
)
|
|
316
|
+
except SessionCommandError as exc:
|
|
317
|
+
self._session_command_failed(
|
|
318
|
+
exc.command or command,
|
|
319
|
+
exc.error_code,
|
|
320
|
+
str(exc),
|
|
321
|
+
)
|
|
322
|
+
return
|
|
323
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
324
|
+
self._session_command_failed(command, "command_failed", str(exc))
|
|
325
|
+
return
|
|
326
|
+
self._emit(result.event())
|
|
327
|
+
|
|
328
|
+
def _handle_approval_response(self, request: Request) -> None:
|
|
329
|
+
with self._state_lock:
|
|
330
|
+
pending = self.pending_approval
|
|
331
|
+
active_run = self.active_run is not None
|
|
332
|
+
if active_run:
|
|
333
|
+
self._fail("runtime_busy", "wait for the active run to finish")
|
|
334
|
+
return
|
|
335
|
+
if pending is None:
|
|
336
|
+
self._fail("no_pending_approval", "there is no action waiting for approval")
|
|
337
|
+
return
|
|
338
|
+
action_id = str(request.get("action_id", "")).strip()
|
|
339
|
+
expected_action_id = str(pending.action.get("id", "")).strip()
|
|
340
|
+
if action_id != expected_action_id:
|
|
341
|
+
self._fail("approval_mismatch", "approval action_id does not match")
|
|
342
|
+
return
|
|
343
|
+
approved = request.get("approved")
|
|
344
|
+
if not isinstance(approved, bool):
|
|
345
|
+
self._fail("invalid_request", "approved must be a boolean")
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
if not approved:
|
|
349
|
+
with self._state_lock:
|
|
350
|
+
self.pending_approval = None
|
|
351
|
+
clear_pending_approval(self.pending_approval_path)
|
|
352
|
+
result = {
|
|
353
|
+
"status": "cancelled",
|
|
354
|
+
"answer": "The requested action was not performed.",
|
|
355
|
+
"goal": pending.runtime_goal,
|
|
356
|
+
"approval": {"action_id": action_id, "approved": "false"},
|
|
357
|
+
}
|
|
358
|
+
self._complete(pending.goal, result)
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
resumable_plan = build_resumable_plan(pending.plan, pending.action)
|
|
362
|
+
if resumable_plan is None:
|
|
363
|
+
self._fail("invalid_approval_state", "pending approval cannot be resumed")
|
|
364
|
+
return
|
|
365
|
+
executing = PendingApproval(
|
|
366
|
+
action=pending.action,
|
|
367
|
+
goal=pending.goal,
|
|
368
|
+
runtime_goal=pending.runtime_goal,
|
|
369
|
+
plan=pending.plan,
|
|
370
|
+
phase="approved_executing",
|
|
371
|
+
)
|
|
372
|
+
save_pending_approval(
|
|
373
|
+
self.pending_approval_path,
|
|
374
|
+
{
|
|
375
|
+
"action": executing.action,
|
|
376
|
+
"goal": executing.goal,
|
|
377
|
+
"runtime_goal": executing.runtime_goal,
|
|
378
|
+
"plan": executing.plan,
|
|
379
|
+
"phase": executing.phase,
|
|
380
|
+
},
|
|
381
|
+
)
|
|
382
|
+
with self._state_lock:
|
|
383
|
+
self.pending_approval = executing
|
|
384
|
+
self._start_active_run(
|
|
385
|
+
pending.goal,
|
|
386
|
+
pending.runtime_goal,
|
|
387
|
+
provider=FakeLLMProvider(
|
|
388
|
+
json.dumps(resumable_plan, ensure_ascii=False, sort_keys=True)
|
|
389
|
+
),
|
|
390
|
+
max_iterations=1,
|
|
391
|
+
approved_action_ids={action_id},
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def _start_active_run(
|
|
395
|
+
self,
|
|
396
|
+
goal: str,
|
|
397
|
+
runtime_goal: str,
|
|
398
|
+
**run_kwargs: Any,
|
|
399
|
+
) -> None:
|
|
400
|
+
run_kwargs.setdefault("stream_answers", True)
|
|
401
|
+
token = RuntimeCancellationToken()
|
|
402
|
+
steering_buffer = RuntimeSteeringBuffer()
|
|
403
|
+
with self._state_lock:
|
|
404
|
+
if self.active_run is not None:
|
|
405
|
+
self._fail("runtime_busy", "wait for the active run to finish")
|
|
406
|
+
return
|
|
407
|
+
self._run_generation += 1
|
|
408
|
+
generation = self._run_generation
|
|
409
|
+
thread = threading.Thread(
|
|
410
|
+
target=self._run_worker,
|
|
411
|
+
args=(
|
|
412
|
+
generation,
|
|
413
|
+
goal,
|
|
414
|
+
runtime_goal,
|
|
415
|
+
token,
|
|
416
|
+
steering_buffer,
|
|
417
|
+
run_kwargs,
|
|
418
|
+
),
|
|
419
|
+
name=f"kagent-stdio-run-{generation}",
|
|
420
|
+
daemon=False,
|
|
421
|
+
)
|
|
422
|
+
self.active_run = ActiveRun(generation, token, steering_buffer, thread)
|
|
423
|
+
self._state_changed.notify_all()
|
|
424
|
+
thread.start()
|
|
425
|
+
|
|
426
|
+
def _run_worker(
|
|
427
|
+
self,
|
|
428
|
+
generation: int,
|
|
429
|
+
goal: str,
|
|
430
|
+
runtime_goal: str,
|
|
431
|
+
token: RuntimeCancellationToken,
|
|
432
|
+
steering_buffer: RuntimeSteeringBuffer,
|
|
433
|
+
run_kwargs: Dict[str, Any],
|
|
434
|
+
) -> None:
|
|
435
|
+
try:
|
|
436
|
+
result = run_runtime_agent(
|
|
437
|
+
runtime_goal,
|
|
438
|
+
cancellation_token=token,
|
|
439
|
+
steering_buffer=steering_buffer,
|
|
440
|
+
event_sink=self._progress_sink,
|
|
441
|
+
**run_kwargs,
|
|
442
|
+
)
|
|
443
|
+
with self._state_lock:
|
|
444
|
+
if not self._is_active_generation(generation):
|
|
445
|
+
return
|
|
446
|
+
pending_instruction, pending_revision = steering_buffer.close()
|
|
447
|
+
if pending_instruction:
|
|
448
|
+
self._steer_failed(
|
|
449
|
+
"steering_too_late",
|
|
450
|
+
"active run reached its final boundary before the instruction applied",
|
|
451
|
+
revision=pending_revision,
|
|
452
|
+
)
|
|
453
|
+
self._finish_run(generation, goal, runtime_goal, result)
|
|
454
|
+
except Exception as exc: # pragma: no cover - defensive protocol boundary
|
|
455
|
+
with self._state_lock:
|
|
456
|
+
steering_buffer.close()
|
|
457
|
+
pending = self.pending_approval
|
|
458
|
+
self._clear_active_run_locked(generation)
|
|
459
|
+
if pending and pending.phase == "approved_executing":
|
|
460
|
+
self.pending_approval = None
|
|
461
|
+
self._fail(
|
|
462
|
+
"approval_execution_interrupted",
|
|
463
|
+
_APPROVAL_EXECUTION_INTERRUPTED_MESSAGE,
|
|
464
|
+
)
|
|
465
|
+
else:
|
|
466
|
+
self._fail("runtime_error", str(exc))
|
|
467
|
+
|
|
468
|
+
def _finish_run(
|
|
469
|
+
self,
|
|
470
|
+
generation: int,
|
|
471
|
+
goal: str,
|
|
472
|
+
runtime_goal: str,
|
|
473
|
+
result: Any,
|
|
474
|
+
) -> None:
|
|
475
|
+
payload = json_ready(result)
|
|
476
|
+
if not isinstance(payload, dict):
|
|
477
|
+
self._clear_active_run_locked(generation)
|
|
478
|
+
self._fail("runtime_error", "runtime result must be an object")
|
|
479
|
+
return
|
|
480
|
+
payload["goal"] = goal
|
|
481
|
+
if payload.get("status") == "requires_approval":
|
|
482
|
+
pending = _pending_approval_from_result(payload, goal, runtime_goal)
|
|
483
|
+
if pending is None:
|
|
484
|
+
self._clear_active_run_locked(generation)
|
|
485
|
+
self._fail("invalid_approval_state", "runtime approval state is incomplete")
|
|
486
|
+
return
|
|
487
|
+
save_pending_approval(
|
|
488
|
+
self.pending_approval_path,
|
|
489
|
+
{
|
|
490
|
+
"action": pending.action,
|
|
491
|
+
"goal": pending.goal,
|
|
492
|
+
"runtime_goal": pending.runtime_goal,
|
|
493
|
+
"plan": pending.plan,
|
|
494
|
+
"phase": pending.phase,
|
|
495
|
+
},
|
|
496
|
+
)
|
|
497
|
+
with self._state_lock:
|
|
498
|
+
if not self._is_active_generation(generation):
|
|
499
|
+
clear_pending_approval(self.pending_approval_path)
|
|
500
|
+
return
|
|
501
|
+
self.pending_approval = pending
|
|
502
|
+
self._clear_active_run_locked(generation)
|
|
503
|
+
self._emit(_approval_event(pending.action))
|
|
504
|
+
return
|
|
505
|
+
self._complete(goal, payload, generation=generation)
|
|
506
|
+
|
|
507
|
+
def _complete(
|
|
508
|
+
self,
|
|
509
|
+
goal: str,
|
|
510
|
+
payload: Dict[str, Any],
|
|
511
|
+
*,
|
|
512
|
+
generation: int | None = None,
|
|
513
|
+
) -> None:
|
|
514
|
+
with self._state_lock:
|
|
515
|
+
if generation is not None and not self._is_active_generation(generation):
|
|
516
|
+
return
|
|
517
|
+
self.pending_approval = None
|
|
518
|
+
clear_pending_approval(self.pending_approval_path)
|
|
519
|
+
remember_runtime_turn(self.memory, goal, payload)
|
|
520
|
+
save_runtime_session_memory(self.memory_path, self.memory)
|
|
521
|
+
with self._state_lock:
|
|
522
|
+
self.last_payload = dict(payload)
|
|
523
|
+
if generation is not None:
|
|
524
|
+
self._clear_active_run_locked(generation)
|
|
525
|
+
self._emit(
|
|
526
|
+
{
|
|
527
|
+
"type": "run_completed",
|
|
528
|
+
"status": str(payload.get("status", "done")),
|
|
529
|
+
"answer": str(payload.get("answer", "")),
|
|
530
|
+
"payload": _completion_payload(payload),
|
|
531
|
+
},
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
def _progress_sink(self, event: Dict[str, Any]) -> None:
|
|
535
|
+
self._emit({"type": "run_progress", "event": event})
|
|
536
|
+
|
|
537
|
+
def _fail(self, error_code: str, message: str) -> None:
|
|
538
|
+
self._emit(
|
|
539
|
+
{"type": "run_failed", "error_code": error_code, "message": message},
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def _clear_active_run(self, generation: int) -> None:
|
|
543
|
+
with self._state_lock:
|
|
544
|
+
self._clear_active_run_locked(generation)
|
|
545
|
+
|
|
546
|
+
def _clear_active_run_locked(self, generation: int) -> None:
|
|
547
|
+
if self._is_active_generation(generation):
|
|
548
|
+
self.active_run = None
|
|
549
|
+
self._state_changed.notify_all()
|
|
550
|
+
|
|
551
|
+
def _is_active_generation(self, generation: int) -> bool:
|
|
552
|
+
return self.active_run is not None and self.active_run.generation == generation
|
|
553
|
+
|
|
554
|
+
def _emit(self, payload: Dict[str, Any]) -> None:
|
|
555
|
+
with self._stdout_lock:
|
|
556
|
+
_emit(self.stdout, payload)
|
|
557
|
+
|
|
558
|
+
def wait_until_idle(self) -> None:
|
|
559
|
+
with self._state_changed:
|
|
560
|
+
while self.active_run is not None:
|
|
561
|
+
self._state_changed.wait()
|
|
562
|
+
|
|
563
|
+
def _provider_configuration_failed(
|
|
564
|
+
self,
|
|
565
|
+
error_code: str,
|
|
566
|
+
message: str,
|
|
567
|
+
*,
|
|
568
|
+
field: str = "",
|
|
569
|
+
) -> None:
|
|
570
|
+
payload = {
|
|
571
|
+
"type": "provider_configuration_failed",
|
|
572
|
+
"error_code": error_code,
|
|
573
|
+
"message": message,
|
|
574
|
+
}
|
|
575
|
+
if field:
|
|
576
|
+
payload["field"] = field
|
|
577
|
+
self._emit(payload)
|
|
578
|
+
|
|
579
|
+
def _session_command_failed(
|
|
580
|
+
self,
|
|
581
|
+
command: str,
|
|
582
|
+
error_code: str,
|
|
583
|
+
message: str,
|
|
584
|
+
) -> None:
|
|
585
|
+
self._emit(
|
|
586
|
+
{
|
|
587
|
+
"type": "session_command_failed",
|
|
588
|
+
"command": command,
|
|
589
|
+
"error_code": error_code,
|
|
590
|
+
"message": message,
|
|
591
|
+
},
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
def _steer_failed(
|
|
595
|
+
self,
|
|
596
|
+
error_code: str,
|
|
597
|
+
message: str,
|
|
598
|
+
*,
|
|
599
|
+
revision: str = "",
|
|
600
|
+
) -> None:
|
|
601
|
+
payload = {
|
|
602
|
+
"type": "run_steer_rejected",
|
|
603
|
+
"error_code": error_code,
|
|
604
|
+
"message": message,
|
|
605
|
+
}
|
|
606
|
+
if revision:
|
|
607
|
+
payload["revision"] = revision
|
|
608
|
+
self._emit(payload)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def main() -> None:
|
|
612
|
+
warnings.filterwarnings("ignore")
|
|
613
|
+
run_stdio_runtime(sys.stdin, sys.stdout)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def run_stdio_runtime(stdin: TextIO, stdout: TextIO) -> None:
|
|
617
|
+
try:
|
|
618
|
+
session = StdioRuntimeSession(stdout)
|
|
619
|
+
except (OSError, ValueError) as exc:
|
|
620
|
+
_emit(
|
|
621
|
+
stdout,
|
|
622
|
+
{
|
|
623
|
+
"type": "runtime_unavailable",
|
|
624
|
+
"message": str(exc),
|
|
625
|
+
},
|
|
626
|
+
)
|
|
627
|
+
return
|
|
628
|
+
_emit(stdout, session.ready_event())
|
|
629
|
+
recovered_approval = session.recovered_approval_event()
|
|
630
|
+
if recovered_approval is not None:
|
|
631
|
+
_emit(stdout, recovered_approval)
|
|
632
|
+
for line in stdin:
|
|
633
|
+
line = line.strip()
|
|
634
|
+
if not line:
|
|
635
|
+
continue
|
|
636
|
+
try:
|
|
637
|
+
request = _parse_request(line)
|
|
638
|
+
except ValueError as exc:
|
|
639
|
+
session._fail("invalid_json", str(exc))
|
|
640
|
+
continue
|
|
641
|
+
if request.get("type") != "cancel_request":
|
|
642
|
+
session.wait_until_idle()
|
|
643
|
+
session.handle(request)
|
|
644
|
+
session.wait_until_idle()
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _parse_request(line: str) -> Request:
|
|
648
|
+
try:
|
|
649
|
+
payload = json.loads(line)
|
|
650
|
+
except json.JSONDecodeError as exc:
|
|
651
|
+
raise ValueError(f"invalid JSON request: {exc.msg}") from exc
|
|
652
|
+
if not isinstance(payload, dict):
|
|
653
|
+
raise ValueError("request must be a JSON object")
|
|
654
|
+
return payload
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _pending_approval_from_result(
|
|
658
|
+
payload: Dict[str, Any],
|
|
659
|
+
goal: str,
|
|
660
|
+
runtime_goal: str,
|
|
661
|
+
) -> PendingApproval | None:
|
|
662
|
+
action = payload.get("pending_approval")
|
|
663
|
+
plan = payload.get("plan")
|
|
664
|
+
if not isinstance(action, dict) or not isinstance(plan, dict):
|
|
665
|
+
return None
|
|
666
|
+
if not str(action.get("id", "")).strip():
|
|
667
|
+
return None
|
|
668
|
+
return PendingApproval(
|
|
669
|
+
dict(action),
|
|
670
|
+
goal,
|
|
671
|
+
runtime_goal,
|
|
672
|
+
dict(plan),
|
|
673
|
+
"awaiting_approval",
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _approval_event(action: Dict[str, Any]) -> Dict[str, Any]:
|
|
678
|
+
action_input = action.get("input")
|
|
679
|
+
safe_input = action_input if isinstance(action_input, dict) else {}
|
|
680
|
+
event = {
|
|
681
|
+
"type": "approval_required",
|
|
682
|
+
"action_id": str(action.get("id", "")),
|
|
683
|
+
"title": _approval_title(str(action.get("tool", ""))),
|
|
684
|
+
"reason": _bounded_text(action.get("reason"), 500),
|
|
685
|
+
"target": _approval_target(safe_input),
|
|
686
|
+
}
|
|
687
|
+
details = _approval_details(safe_input)
|
|
688
|
+
if details:
|
|
689
|
+
event["details"] = details
|
|
690
|
+
return event
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _approval_title(tool: str) -> str:
|
|
694
|
+
return {
|
|
695
|
+
"open_url": "Open a website",
|
|
696
|
+
"open_app": "Open an application",
|
|
697
|
+
"http_request": "Contact an external service",
|
|
698
|
+
"shell_command": "Run a system command",
|
|
699
|
+
"apply_patch": "Modify workspace files",
|
|
700
|
+
"revert_patch": "Restore workspace files",
|
|
701
|
+
"write_file": "Create or update a file",
|
|
702
|
+
}.get(tool, "Perform an external action")
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _approval_target(action_input: Dict[str, Any]) -> str:
|
|
706
|
+
paths = action_input.get("paths")
|
|
707
|
+
if isinstance(paths, list):
|
|
708
|
+
visible = [str(path) for path in paths[:3]]
|
|
709
|
+
suffix = f", +{len(paths) - len(visible)} more" if len(paths) > 3 else ""
|
|
710
|
+
return _bounded_text(
|
|
711
|
+
f"{len(paths)} files: {', '.join(visible)}{suffix}",
|
|
712
|
+
500,
|
|
713
|
+
)
|
|
714
|
+
for key in ("url", "application", "path", "command", "query"):
|
|
715
|
+
if key in action_input:
|
|
716
|
+
return _bounded_text(action_input[key], 500)
|
|
717
|
+
return ""
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _approval_details(action_input: Dict[str, Any]) -> list[str]:
|
|
721
|
+
paths = action_input.get("paths")
|
|
722
|
+
if not isinstance(paths, list):
|
|
723
|
+
return []
|
|
724
|
+
return [_bounded_text(path, 500) for path in paths]
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _completion_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
728
|
+
allowed_keys = (
|
|
729
|
+
"duration_seconds",
|
|
730
|
+
"iteration_count",
|
|
731
|
+
"max_iterations",
|
|
732
|
+
"run_id",
|
|
733
|
+
"status",
|
|
734
|
+
)
|
|
735
|
+
return {key: payload[key] for key in allowed_keys if key in payload}
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _bounded_text(value: Any, limit: int) -> str:
|
|
739
|
+
text = " ".join(redact_runtime_session_memory_text(str(value or "")).split())
|
|
740
|
+
if len(text) <= limit:
|
|
741
|
+
return text
|
|
742
|
+
return text[: limit - 3] + "..."
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _provider_from_request(request: Request, config: LLMProviderConfig) -> Any:
|
|
746
|
+
runtime_plan = str(request.get("runtime_plan", "")).strip()
|
|
747
|
+
if runtime_plan:
|
|
748
|
+
return FakeLLMProvider(runtime_plan)
|
|
749
|
+
missing = missing_provider_config_fields(config)
|
|
750
|
+
if missing:
|
|
751
|
+
raise RuntimeProviderConfigError(runtime_provider_config_message(missing))
|
|
752
|
+
return build_llm_provider(config)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _provider_error_field(message: str) -> str:
|
|
756
|
+
normalized = message.lower()
|
|
757
|
+
if "base_url" in normalized:
|
|
758
|
+
return "base_url"
|
|
759
|
+
if "model" in normalized:
|
|
760
|
+
return "model"
|
|
761
|
+
if "api_key" in normalized:
|
|
762
|
+
return "api_key"
|
|
763
|
+
return ""
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _positive_int(value: Any, *, default: int) -> int:
|
|
767
|
+
if value in (None, ""):
|
|
768
|
+
return default
|
|
769
|
+
parsed = int(value)
|
|
770
|
+
if parsed < 1:
|
|
771
|
+
raise ValueError("max_iterations must be at least 1")
|
|
772
|
+
return parsed
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _emit(stdout: TextIO, payload: Dict[str, Any]) -> None:
|
|
776
|
+
stdout.write(json.dumps(json_ready(payload), ensure_ascii=False, sort_keys=True) + "\n")
|
|
777
|
+
stdout.flush()
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
__all__: Iterable[str] = ["StdioRuntimeSession", "run_stdio_runtime", "main"]
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
if __name__ == "__main__":
|
|
784
|
+
main()
|