@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,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()