tamfis-code 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tamfis_code/__init__.py +58 -0
- tamfis_code/__main__.py +4 -0
- tamfis_code/agents.py +371 -0
- tamfis_code/api_client.py +461 -0
- tamfis_code/cli.py +2351 -0
- tamfis_code/completion.py +137 -0
- tamfis_code/config.py +199 -0
- tamfis_code/doctor.py +173 -0
- tamfis_code/enforcer.py +318 -0
- tamfis_code/indexer.py +567 -0
- tamfis_code/instructions.py +121 -0
- tamfis_code/interactive.py +985 -0
- tamfis_code/local_chat.py +117 -0
- tamfis_code/local_tools.py +93 -0
- tamfis_code/mcp.py +538 -0
- tamfis_code/metrics.py +105 -0
- tamfis_code/providers.py +423 -0
- tamfis_code/references.py +336 -0
- tamfis_code/render.py +575 -0
- tamfis_code/runner.py +635 -0
- tamfis_code/runner_local.py +364 -0
- tamfis_code/safety.py +164 -0
- tamfis_code/screenshot.py +382 -0
- tamfis_code/sessions.py +253 -0
- tamfis_code/state.py +449 -0
- tamfis_code/tasks.py +42 -0
- tamfis_code/workspace.py +329 -0
- tamfis_code-0.2.3.dist-info/METADATA +78 -0
- tamfis_code-0.2.3.dist-info/RECORD +32 -0
- tamfis_code-0.2.3.dist-info/WHEEL +5 -0
- tamfis_code-0.2.3.dist-info/entry_points.txt +4 -0
- tamfis_code-0.2.3.dist-info/top_level.txt +1 -0
tamfis_code/runner.py
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"""Task/command execution + streaming loop, shared by interactive and
|
|
2
|
+
non-interactive commands. Supports multiple providers: Ollama, HF, NVIDIA,
|
|
3
|
+
OpenRouter, and auto-fallback.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
import traceback
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Optional, AsyncGenerator
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
|
|
19
|
+
from .api_client import AuthRequiredError, RemoteAPIClient, RemoteAPIError
|
|
20
|
+
from .render import StreamRenderer
|
|
21
|
+
from . import state as local_state
|
|
22
|
+
from .providers import ProviderManager, ProviderType
|
|
23
|
+
|
|
24
|
+
COMMAND_POLL_INTERVAL_SECONDS = 0.2
|
|
25
|
+
TERMINAL_COMMAND_STATUSES = {"completed", "failed", "denied", "cancelled"}
|
|
26
|
+
APPROVAL_PREVIEW_CHARS = 8_000
|
|
27
|
+
ACTIVE_TASK_STATUSES = {"running", "pending", "queued", "pending_approval"}
|
|
28
|
+
try:
|
|
29
|
+
MAX_TASK_STREAM_RECONNECTS = min(
|
|
30
|
+
30, max(3, int(os.getenv("TAMFIS_CODE_STREAM_RECONNECTS", "12")))
|
|
31
|
+
)
|
|
32
|
+
except (TypeError, ValueError):
|
|
33
|
+
MAX_TASK_STREAM_RECONNECTS = 12
|
|
34
|
+
|
|
35
|
+
# Allowed providers - expanded from just hf/openrouter
|
|
36
|
+
ALLOWED_PROVIDERS = ["hf", "huggingface", "or", "openrouter", "ollama", "nvidia", "nvidia_nim", "gemini", "apiframe", "auto", None]
|
|
37
|
+
PROVIDER_ALIASES = {"hf": "huggingface", "nvidia": "nvidia_nim", "or": "openrouter"}
|
|
38
|
+
|
|
39
|
+
# Provider name mapping
|
|
40
|
+
PROVIDER_NAME_MAP = {
|
|
41
|
+
"hf": "Hugging Face",
|
|
42
|
+
"huggingface": "Hugging Face",
|
|
43
|
+
"openrouter": "OpenRouter",
|
|
44
|
+
"ollama": "Ollama (Local)",
|
|
45
|
+
"nvidia": "NVIDIA NIM",
|
|
46
|
+
"nvidia_nim": "NVIDIA NIM",
|
|
47
|
+
"gemini": "Google Gemini",
|
|
48
|
+
"apiframe": "APIFRAME",
|
|
49
|
+
"auto": "Auto (server-selected)",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def normalize_provider(provider: Optional[str]) -> str:
|
|
54
|
+
if provider not in ALLOWED_PROVIDERS:
|
|
55
|
+
raise ValueError(f"Unsupported provider: {provider}")
|
|
56
|
+
return PROVIDER_ALIASES.get(provider, provider) or "auto"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class TaskOutcome:
|
|
61
|
+
status: str
|
|
62
|
+
summary: Optional[str] = None
|
|
63
|
+
error: Optional[str] = None
|
|
64
|
+
plan_id: Optional[str] = None
|
|
65
|
+
plan_items: list[dict[str, Any]] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_approval_decision(
|
|
69
|
+
console: Console, command_text: str, risk: str, approval_policy: str, interactive: bool,
|
|
70
|
+
*, display_preview: bool = True,
|
|
71
|
+
) -> str:
|
|
72
|
+
risk = (risk or "medium").lower()
|
|
73
|
+
|
|
74
|
+
if approval_policy in {"auto", "full-auto"}:
|
|
75
|
+
return "approve_once"
|
|
76
|
+
if approval_policy in {"safe", "workspace", "accept-edits"}:
|
|
77
|
+
return "approve_once" if risk != "dangerous" else ("deny" if not interactive else _prompt(console, command_text, risk, display_preview))
|
|
78
|
+
if approval_policy in {"read-only", "plan-only", "suggest", "never"}:
|
|
79
|
+
return "deny"
|
|
80
|
+
if not interactive:
|
|
81
|
+
return "deny"
|
|
82
|
+
return _prompt(console, command_text, risk, display_preview)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def approval_command_preview(command_text: str, limit: int = APPROVAL_PREVIEW_CHARS) -> str:
|
|
86
|
+
if len(command_text) <= limit:
|
|
87
|
+
return command_text
|
|
88
|
+
head = int(limit * 0.7)
|
|
89
|
+
tail = limit - head
|
|
90
|
+
omitted = len(command_text) - limit
|
|
91
|
+
return (
|
|
92
|
+
command_text[:head]
|
|
93
|
+
+ f"\n\n… {omitted:,} characters omitted from approval preview …\n\n"
|
|
94
|
+
+ command_text[-tail:]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _install_sigint_watcher() -> tuple[asyncio.Event, "Any"]:
|
|
99
|
+
loop = asyncio.get_running_loop()
|
|
100
|
+
event = asyncio.Event()
|
|
101
|
+
installed = False
|
|
102
|
+
try:
|
|
103
|
+
loop.add_signal_handler(signal.SIGINT, event.set)
|
|
104
|
+
installed = True
|
|
105
|
+
except (NotImplementedError, RuntimeError):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def uninstall() -> None:
|
|
109
|
+
if installed:
|
|
110
|
+
try:
|
|
111
|
+
loop.remove_signal_handler(signal.SIGINT)
|
|
112
|
+
except (NotImplementedError, RuntimeError):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
return event, uninstall
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _prompt(console: Console, command_text: str, risk: str, display_preview: bool = True) -> str:
|
|
119
|
+
if display_preview:
|
|
120
|
+
console.print(Panel(approval_command_preview(command_text), title=f"Approval required — risk: {risk}", border_style="magenta", expand=False))
|
|
121
|
+
while True:
|
|
122
|
+
answer = console.input("[bold]Approve? (y)es / (n)o / (a)lways this session: [/bold]").strip().lower()
|
|
123
|
+
if answer in ("y", "yes"):
|
|
124
|
+
return "approve_once"
|
|
125
|
+
if answer in ("a", "always"):
|
|
126
|
+
return "approve_session"
|
|
127
|
+
if answer in ("n", "no", ""):
|
|
128
|
+
return "deny"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def submit_ai_task_background(
|
|
132
|
+
client: RemoteAPIClient, *, session_id: int, objective: str, mode: str,
|
|
133
|
+
model: str = "auto", provider: Optional[str] = None,
|
|
134
|
+
attachments: Optional[list[dict[str, Any]]] = None,
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
provider = normalize_provider(provider)
|
|
137
|
+
task = await client.run_ai_task(
|
|
138
|
+
session_id, objective, mode, model=model, provider=provider, attachments=attachments,
|
|
139
|
+
)
|
|
140
|
+
task_id = str(task["task_id"])
|
|
141
|
+
action = local_state.start_action(
|
|
142
|
+
session_id, action_type="ai_task", purpose=objective,
|
|
143
|
+
risk="read_only" if mode in {"chat", "audit", "plan"} else "workspace_write",
|
|
144
|
+
detail=f"mode={mode}; background=true",
|
|
145
|
+
)
|
|
146
|
+
# finish_action() unconditionally resets execution_status to "idle"/
|
|
147
|
+
# "running" as a side effect of closing out its own local bookkeeping --
|
|
148
|
+
# it must run before the save_session_state() call below sets the real
|
|
149
|
+
# status, not after, or "backgrounded" gets clobbered back to "idle"
|
|
150
|
+
# immediately even though a real task is now running server-side.
|
|
151
|
+
local_state.finish_action(session_id, action.id, status="completed", summary=f"submitted task {task_id}")
|
|
152
|
+
local_state.save_session_state(
|
|
153
|
+
session_id, last_task_id=task_id, active_task={"id": task_id, "objective": objective, "mode": mode},
|
|
154
|
+
current_phase="queued", execution_status="backgrounded",
|
|
155
|
+
)
|
|
156
|
+
local_state.checkpoint(session_id, reason="background_task_submitted", summary=objective)
|
|
157
|
+
return task
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def _print_command_budget_if_notable(client: RemoteAPIClient, console: Console, task_id: str) -> None:
|
|
161
|
+
"""Surface command-budget usage once a task finishes, but only when it's
|
|
162
|
+
actually informative -- most short tasks use a handful of commands out
|
|
163
|
+
of a budget in the hundreds, and printing that every time would just be
|
|
164
|
+
noise. Silently does nothing if the task/budget fields aren't available
|
|
165
|
+
(older server, or the request itself fails) -- this is purely
|
|
166
|
+
informational and must never affect the task's real outcome."""
|
|
167
|
+
try:
|
|
168
|
+
task = await client.get_task(task_id)
|
|
169
|
+
except (AuthRequiredError, RemoteAPIError):
|
|
170
|
+
return
|
|
171
|
+
budget = task.get("command_budget")
|
|
172
|
+
count = task.get("command_count")
|
|
173
|
+
if not budget or count is None:
|
|
174
|
+
return
|
|
175
|
+
if count >= budget * 0.8:
|
|
176
|
+
style = "red" if count >= budget else "yellow"
|
|
177
|
+
console.print(f"[{style}]Commands used: {count}/{budget} for this task.[/{style}]")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def run_ai_task_and_stream(
|
|
181
|
+
client: RemoteAPIClient,
|
|
182
|
+
renderer: StreamRenderer,
|
|
183
|
+
console: Console,
|
|
184
|
+
*,
|
|
185
|
+
session_id: int,
|
|
186
|
+
objective: str,
|
|
187
|
+
mode: str,
|
|
188
|
+
approval_policy: str,
|
|
189
|
+
interactive: bool,
|
|
190
|
+
model: str = "auto",
|
|
191
|
+
provider: Optional[str] = None,
|
|
192
|
+
attachments: Optional[list[dict[str, Any]]] = None,
|
|
193
|
+
) -> TaskOutcome:
|
|
194
|
+
# Normalize aliases at the boundary. Explicit routes are never silently
|
|
195
|
+
# replaced with auto or a different provider.
|
|
196
|
+
provider = normalize_provider(provider)
|
|
197
|
+
|
|
198
|
+
provider_name = PROVIDER_NAME_MAP.get(provider or "auto", provider or "auto")
|
|
199
|
+
console.print(f"[dim]Using provider: {provider_name}[/dim]")
|
|
200
|
+
|
|
201
|
+
# Preserve auto selection for Tier IV. The CLI must not silently pin
|
|
202
|
+
# automatic requests to Ollama or OpenRouter; the orchestration layer
|
|
203
|
+
# has provider health, capability and workspace-tier information.
|
|
204
|
+
if provider == "auto":
|
|
205
|
+
provider = "auto"
|
|
206
|
+
|
|
207
|
+
task = await client.run_ai_task(
|
|
208
|
+
session_id, objective, mode, model=model, provider=provider, attachments=attachments,
|
|
209
|
+
)
|
|
210
|
+
task_id = str(task["task_id"])
|
|
211
|
+
action = local_state.start_action(
|
|
212
|
+
session_id, action_type="ai_task", purpose=objective,
|
|
213
|
+
risk="read_only" if mode in {"chat", "audit", "plan"} else "workspace_write", detail=f"mode={mode}",
|
|
214
|
+
)
|
|
215
|
+
local_state.save_session_state(
|
|
216
|
+
session_id, last_task_id=task_id, active_task={"id": task_id, "objective": objective, "mode": mode},
|
|
217
|
+
current_phase="understand", execution_status="running",
|
|
218
|
+
)
|
|
219
|
+
try:
|
|
220
|
+
# Resume from the durable session cursor. Replaying a long-lived
|
|
221
|
+
# workspace from event 1 on every new task delays visible output and
|
|
222
|
+
# can make a healthy task look hung while hundreds of irrelevant old
|
|
223
|
+
# events are filtered client-side.
|
|
224
|
+
stream_cursor = local_state.get_session_state(session_id).last_event_id
|
|
225
|
+
outcome = await _stream_task(
|
|
226
|
+
client, renderer, console, session_id=session_id, task_id=task_id,
|
|
227
|
+
approval_policy=approval_policy, interactive=interactive,
|
|
228
|
+
from_event_id=stream_cursor,
|
|
229
|
+
)
|
|
230
|
+
except BaseException:
|
|
231
|
+
local_state.finish_action(session_id, action.id, status="failed", summary="stream failed")
|
|
232
|
+
raise
|
|
233
|
+
# A live-branched instruction may have continued this task under a new
|
|
234
|
+
# task_id (see _stream_task's task_continued handling) -- last_task_id
|
|
235
|
+
# tracks whichever one actually finished.
|
|
236
|
+
final_task_id = local_state.get_session_state(session_id).last_task_id or task_id
|
|
237
|
+
await _print_command_budget_if_notable(client, console, final_task_id)
|
|
238
|
+
local_state.finish_action(session_id, action.id, status=outcome.status, summary=outcome.summary or outcome.error or "")
|
|
239
|
+
if mode == "plan" and outcome.status == "completed" and outcome.summary:
|
|
240
|
+
saved = local_state.save_plan(
|
|
241
|
+
session_id, objective=objective, content=outcome.summary, source_task_id=task_id,
|
|
242
|
+
steps=outcome.plan_items,
|
|
243
|
+
)
|
|
244
|
+
outcome.plan_id = saved.id
|
|
245
|
+
console.print(
|
|
246
|
+
f"[green]Plan saved[/green] · {saved.id} · run `/execute-plan {saved.id}` "
|
|
247
|
+
f"in the REPL or `tamfis-code execute-plan {saved.id}` from the shell"
|
|
248
|
+
)
|
|
249
|
+
local_state.save_session_state(session_id, active_task=None, current_phase="report", execution_status=outcome.status)
|
|
250
|
+
local_state.checkpoint(session_id, reason=f"task_{outcome.status}", summary=outcome.summary or outcome.error or "")
|
|
251
|
+
return outcome
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def retry_task_and_stream(
|
|
255
|
+
client: RemoteAPIClient,
|
|
256
|
+
renderer: StreamRenderer,
|
|
257
|
+
console: Console,
|
|
258
|
+
*,
|
|
259
|
+
session_id: int,
|
|
260
|
+
task_id: str,
|
|
261
|
+
mode: Optional[str],
|
|
262
|
+
approval_policy: str,
|
|
263
|
+
interactive: bool,
|
|
264
|
+
) -> TaskOutcome:
|
|
265
|
+
retried = await client.retry_task(task_id, mode)
|
|
266
|
+
new_task_id = str(retried["task_id"])
|
|
267
|
+
console.print(f"[dim]Retrying as task {new_task_id}[/dim]")
|
|
268
|
+
local_state.save_session_state(session_id, last_task_id=new_task_id)
|
|
269
|
+
stream_cursor = local_state.get_session_state(session_id).last_event_id
|
|
270
|
+
outcome = await _stream_task(
|
|
271
|
+
client, renderer, console,
|
|
272
|
+
session_id=session_id, task_id=new_task_id,
|
|
273
|
+
approval_policy=approval_policy, interactive=interactive,
|
|
274
|
+
from_event_id=stream_cursor,
|
|
275
|
+
)
|
|
276
|
+
await _print_command_budget_if_notable(client, console, new_task_id)
|
|
277
|
+
return outcome
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def attach_and_stream(
|
|
281
|
+
client: RemoteAPIClient,
|
|
282
|
+
renderer: StreamRenderer,
|
|
283
|
+
console: Console,
|
|
284
|
+
*,
|
|
285
|
+
session_id: int,
|
|
286
|
+
task_id: str,
|
|
287
|
+
approval_policy: str,
|
|
288
|
+
interactive: bool,
|
|
289
|
+
) -> TaskOutcome:
|
|
290
|
+
from_event_id = local_state.get_session_state(session_id).last_event_id
|
|
291
|
+
return await _stream_task(
|
|
292
|
+
client, renderer, console,
|
|
293
|
+
session_id=session_id, task_id=task_id,
|
|
294
|
+
approval_policy=approval_policy, interactive=interactive,
|
|
295
|
+
from_event_id=from_event_id, on_interrupt="detach",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def _stream_task(
|
|
300
|
+
client: RemoteAPIClient,
|
|
301
|
+
renderer: StreamRenderer,
|
|
302
|
+
console: Console,
|
|
303
|
+
*,
|
|
304
|
+
session_id: int,
|
|
305
|
+
task_id: str,
|
|
306
|
+
approval_policy: str,
|
|
307
|
+
interactive: bool,
|
|
308
|
+
from_event_id: int = 0,
|
|
309
|
+
on_interrupt: str = "cancel",
|
|
310
|
+
reconnect_attempt: int = 0,
|
|
311
|
+
) -> TaskOutcome:
|
|
312
|
+
outcome: Optional[TaskOutcome] = None
|
|
313
|
+
last_assistant_content: Optional[str] = None
|
|
314
|
+
last_plan_items: list[dict[str, Any]] = []
|
|
315
|
+
prompted_command_ids: set[int] = set()
|
|
316
|
+
interrupted, uninstall_sigint = _install_sigint_watcher()
|
|
317
|
+
queued_interrupt: Optional[dict[str, Any]] = None
|
|
318
|
+
|
|
319
|
+
async def watch_instruction_queue() -> None:
|
|
320
|
+
nonlocal queued_interrupt
|
|
321
|
+
while not interrupted.is_set():
|
|
322
|
+
state = local_state.get_session_state(session_id)
|
|
323
|
+
queued_interrupt = next((
|
|
324
|
+
item for item in state.queued_user_instructions
|
|
325
|
+
if item.get("status") == "queued"
|
|
326
|
+
and item.get("classification") in {"cancel", "replace", "reprioritise"}
|
|
327
|
+
), None)
|
|
328
|
+
if queued_interrupt is not None:
|
|
329
|
+
interrupted.set()
|
|
330
|
+
return
|
|
331
|
+
await asyncio.sleep(0.25)
|
|
332
|
+
|
|
333
|
+
async def consume() -> None:
|
|
334
|
+
nonlocal outcome, last_assistant_content, last_plan_items, task_id
|
|
335
|
+
async for event in client.stream_session(session_id, from_event_id):
|
|
336
|
+
sequence = event.get("stream_sequence")
|
|
337
|
+
if sequence is not None:
|
|
338
|
+
local_state.save_session_state(session_id, last_event_id=int(sequence))
|
|
339
|
+
|
|
340
|
+
event_task_id = event.get("task_id")
|
|
341
|
+
event_type = event.get("event_type") or event.get("event")
|
|
342
|
+
payload = event.get("payload") or {}
|
|
343
|
+
|
|
344
|
+
# A live-branched instruction (see LiveInstructionIn/
|
|
345
|
+
# add_task_instruction) starts a real continuation task under a
|
|
346
|
+
# NEW task_id once the current turn finishes at a safe boundary
|
|
347
|
+
# -- this is the SAME session-level stream, so its events are
|
|
348
|
+
# already arriving here. Follow it instead of stopping: from the
|
|
349
|
+
# user's point of view this is still one running task that just
|
|
350
|
+
# incorporated their new guidance and revised its plan.
|
|
351
|
+
if str(event_task_id) == task_id and event_type == "task_continued":
|
|
352
|
+
new_task_id = str(payload.get("continuation_task_id") or "")
|
|
353
|
+
if new_task_id:
|
|
354
|
+
console.print(f"[cyan]◆ Continuing with your instruction[/cyan] (task {new_task_id})")
|
|
355
|
+
task_id = new_task_id
|
|
356
|
+
local_state.save_session_state(
|
|
357
|
+
session_id, last_task_id=task_id,
|
|
358
|
+
active_task={"id": task_id, "objective": str(payload.get("instruction") or ""), "mode": "coding"},
|
|
359
|
+
)
|
|
360
|
+
continue
|
|
361
|
+
|
|
362
|
+
if str(event_task_id) != task_id:
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
phase_by_event = {
|
|
366
|
+
"plan_created": "plan", "tool_call_requested": "execute",
|
|
367
|
+
"command_started": "execute", "file_mutation": "execute",
|
|
368
|
+
"approval_required": "waiting_for_approval", "task_diagnostics": "validate",
|
|
369
|
+
"ai_task_completed": "report", "ai_task_failed": "report",
|
|
370
|
+
}
|
|
371
|
+
if event_type in phase_by_event:
|
|
372
|
+
local_state.save_session_state(session_id, current_phase=phase_by_event[event_type])
|
|
373
|
+
|
|
374
|
+
if event_type == "plan_created":
|
|
375
|
+
items = payload.get("items") if isinstance(payload.get("items"), list) else []
|
|
376
|
+
last_plan_items = items
|
|
377
|
+
# If a saved plan is already active (e.g. re-planning mid
|
|
378
|
+
# `execute-plan`), keep its persisted step list current too --
|
|
379
|
+
# for a brand-new `plan` mode task there's no saved plan yet
|
|
380
|
+
# (that only happens once the task completes, below), so
|
|
381
|
+
# `last_plan_items` is what carries this forward in that case.
|
|
382
|
+
state = local_state.get_session_state(session_id)
|
|
383
|
+
if state.active_plan_id:
|
|
384
|
+
local_state.update_plan_steps(session_id, state.active_plan_id, items)
|
|
385
|
+
|
|
386
|
+
if event_type == "file_mutation":
|
|
387
|
+
state = local_state.get_session_state(session_id)
|
|
388
|
+
mutation_id = str(payload.get("mutation_id") or "")
|
|
389
|
+
existing = {str(item.get("mutation_id")): item for item in state.modified_files}
|
|
390
|
+
existing[mutation_id] = {
|
|
391
|
+
"mutation_id": mutation_id, "path": payload.get("path"),
|
|
392
|
+
"original_hash": payload.get("hash_before"), "current_hash": payload.get("hash_after"),
|
|
393
|
+
"pre_existing_changes": bool(state.repository_context.get("dirty")),
|
|
394
|
+
"modified_by_action_ids": [state.running_action.get("id")] if state.running_action else [],
|
|
395
|
+
"validation_status": "pending", "revert_status": "none",
|
|
396
|
+
}
|
|
397
|
+
local_state.save_session_state(session_id, modified_files=list(existing.values()))
|
|
398
|
+
|
|
399
|
+
if event_type == "file_mutation_reverted":
|
|
400
|
+
state = local_state.get_session_state(session_id)
|
|
401
|
+
for item in state.modified_files:
|
|
402
|
+
if str(item.get("mutation_id")) == str(payload.get("mutation_id")):
|
|
403
|
+
item["revert_status"] = "reverted"
|
|
404
|
+
local_state.save_session_state(session_id, modified_files=state.modified_files)
|
|
405
|
+
|
|
406
|
+
if event_type == "task_diagnostics":
|
|
407
|
+
state = local_state.get_session_state(session_id)
|
|
408
|
+
result = {
|
|
409
|
+
"task_id": task_id, "status": payload.get("completion_status"),
|
|
410
|
+
"provider": payload.get("provider"), "model": payload.get("model"),
|
|
411
|
+
"tool_calls": len(payload.get("tool_calls") or []),
|
|
412
|
+
"failed_tool_calls": sum(1 for call in payload.get("tool_calls") or [] if call.get("success") is False),
|
|
413
|
+
}
|
|
414
|
+
state.validation_results = (state.validation_results + [result])[-100:]
|
|
415
|
+
local_state.save_session_state(session_id, validation_results=state.validation_results)
|
|
416
|
+
|
|
417
|
+
if event_type == "tool_output":
|
|
418
|
+
result_envelope = payload.get("result") if isinstance(payload.get("result"), dict) else payload
|
|
419
|
+
evidence = payload.get("evidence") or result_envelope.get("evidence") or []
|
|
420
|
+
file_reads = [
|
|
421
|
+
item for item in evidence
|
|
422
|
+
if isinstance(item, dict) and item.get("type") == "file_read" and item.get("path")
|
|
423
|
+
]
|
|
424
|
+
if file_reads:
|
|
425
|
+
state = local_state.get_session_state(session_id)
|
|
426
|
+
inspected = dict(state.inspected_files)
|
|
427
|
+
for item in file_reads:
|
|
428
|
+
inspected[str(item["path"])] = {
|
|
429
|
+
"path": item["path"],
|
|
430
|
+
"sha256": item.get("sha256"),
|
|
431
|
+
"last_read_at": item.get("timestamp"),
|
|
432
|
+
"tool_call_id": item.get("tool_call_id"),
|
|
433
|
+
}
|
|
434
|
+
local_state.save_session_state(session_id, inspected_files=inspected)
|
|
435
|
+
|
|
436
|
+
if event_type == "assistant_message":
|
|
437
|
+
last_assistant_content = str(payload.get("visible_content", ""))
|
|
438
|
+
|
|
439
|
+
if event_type == "approval_required":
|
|
440
|
+
# Render the full command/cwd/reason/risk card before asking
|
|
441
|
+
# for a decision. The prompt then stays compact and does not
|
|
442
|
+
# duplicate a less-informative second approval panel.
|
|
443
|
+
renderer.handle_event(event)
|
|
444
|
+
command_id = payload.get("command_id")
|
|
445
|
+
if command_id is not None and command_id not in prompted_command_ids:
|
|
446
|
+
prompted_command_ids.add(command_id)
|
|
447
|
+
decision = resolve_approval_decision(
|
|
448
|
+
console,
|
|
449
|
+
str(payload.get("command", "")),
|
|
450
|
+
str(payload.get("risk_level", "medium")),
|
|
451
|
+
approval_policy,
|
|
452
|
+
interactive,
|
|
453
|
+
display_preview=False,
|
|
454
|
+
)
|
|
455
|
+
try:
|
|
456
|
+
await client.approve_command(command_id, decision)
|
|
457
|
+
except RemoteAPIError:
|
|
458
|
+
pass
|
|
459
|
+
|
|
460
|
+
if event_type != "approval_required":
|
|
461
|
+
renderer.handle_event(event)
|
|
462
|
+
|
|
463
|
+
if event_type == "ai_task_completed":
|
|
464
|
+
outcome = TaskOutcome(status="completed", summary=last_assistant_content or "", plan_items=last_plan_items)
|
|
465
|
+
if last_assistant_content:
|
|
466
|
+
local_state.save_session_state(
|
|
467
|
+
session_id, conversation_summary=last_assistant_content[-4000:],
|
|
468
|
+
)
|
|
469
|
+
return
|
|
470
|
+
if event_type == "ai_task_failed":
|
|
471
|
+
outcome = TaskOutcome(status="failed", error=str(payload.get("error", "unknown error")))
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
consume_task = asyncio.ensure_future(consume())
|
|
475
|
+
interrupt_task = asyncio.ensure_future(interrupted.wait())
|
|
476
|
+
queue_task = asyncio.ensure_future(watch_instruction_queue())
|
|
477
|
+
try:
|
|
478
|
+
done, _pending = await asyncio.wait({consume_task, interrupt_task}, return_when=asyncio.FIRST_COMPLETED)
|
|
479
|
+
if consume_task not in done:
|
|
480
|
+
consume_task.cancel()
|
|
481
|
+
try:
|
|
482
|
+
await consume_task
|
|
483
|
+
except asyncio.CancelledError:
|
|
484
|
+
pass
|
|
485
|
+
if on_interrupt == "detach":
|
|
486
|
+
outcome = TaskOutcome(status="detached", summary=task_id)
|
|
487
|
+
else:
|
|
488
|
+
await client.cancel_task(task_id)
|
|
489
|
+
if queued_interrupt and queued_interrupt.get("classification") == "cancel":
|
|
490
|
+
local_state.update_instruction(session_id, str(queued_interrupt.get("id")), "completed")
|
|
491
|
+
reason = "Reprioritised by queued instruction" if queued_interrupt else "Interrupted by user"
|
|
492
|
+
outcome = TaskOutcome(status="cancelled", error=reason)
|
|
493
|
+
else:
|
|
494
|
+
await consume_task
|
|
495
|
+
finally:
|
|
496
|
+
interrupt_task.cancel()
|
|
497
|
+
queue_task.cancel()
|
|
498
|
+
uninstall_sigint()
|
|
499
|
+
renderer.finish()
|
|
500
|
+
|
|
501
|
+
if outcome is None:
|
|
502
|
+
task_status = await client.get_task(task_id)
|
|
503
|
+
status = str(task_status.get("status", "failed"))
|
|
504
|
+
if status == "completed":
|
|
505
|
+
outcome = TaskOutcome(status="completed", summary=task_status.get("final_answer") or "")
|
|
506
|
+
elif status in ACTIVE_TASK_STATUSES:
|
|
507
|
+
if reconnect_attempt < MAX_TASK_STREAM_RECONNECTS:
|
|
508
|
+
delay = min(8.0, 0.5 * (2 ** reconnect_attempt))
|
|
509
|
+
console.print(
|
|
510
|
+
f"[dim]· Event stream closed; reconnecting from the last checkpoint "
|
|
511
|
+
f"({reconnect_attempt + 1}/{MAX_TASK_STREAM_RECONNECTS})...[/dim]"
|
|
512
|
+
)
|
|
513
|
+
await asyncio.sleep(delay)
|
|
514
|
+
cursor = local_state.get_session_state(session_id).last_event_id
|
|
515
|
+
return await _stream_task(
|
|
516
|
+
client, renderer, console,
|
|
517
|
+
session_id=session_id, task_id=task_id,
|
|
518
|
+
approval_policy=approval_policy, interactive=interactive,
|
|
519
|
+
from_event_id=cursor, on_interrupt=on_interrupt,
|
|
520
|
+
reconnect_attempt=reconnect_attempt + 1,
|
|
521
|
+
)
|
|
522
|
+
outcome = TaskOutcome(status="detached", summary=task_id)
|
|
523
|
+
else:
|
|
524
|
+
outcome = TaskOutcome(status=status, error=task_status.get("error"))
|
|
525
|
+
return outcome
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
async def run_shell_command(
|
|
529
|
+
client: RemoteAPIClient,
|
|
530
|
+
console: Console,
|
|
531
|
+
*,
|
|
532
|
+
session_id: int,
|
|
533
|
+
command: str,
|
|
534
|
+
approval_policy: str,
|
|
535
|
+
interactive: bool,
|
|
536
|
+
) -> TaskOutcome:
|
|
537
|
+
action = local_state.start_action(
|
|
538
|
+
session_id, action_type="shell_command", purpose="Run an explicit Remote command",
|
|
539
|
+
risk="policy_classified", detail=command,
|
|
540
|
+
)
|
|
541
|
+
cmd = await client.submit_command(session_id, command)
|
|
542
|
+
command_id = cmd["id"]
|
|
543
|
+
console.print(f"[bold]$[/bold] {command}")
|
|
544
|
+
|
|
545
|
+
prompted = False
|
|
546
|
+
status = str(cmd.get("status", ""))
|
|
547
|
+
interrupted, uninstall_sigint = _install_sigint_watcher()
|
|
548
|
+
try:
|
|
549
|
+
# Approval is durable server state, not a countdown. Keep polling
|
|
550
|
+
# until the user decides or cancels with Ctrl+C; command execution
|
|
551
|
+
# still has its own bounded server-side timeout after approval.
|
|
552
|
+
while True:
|
|
553
|
+
if interrupted.is_set():
|
|
554
|
+
cmd = await client.cancel_command(command_id)
|
|
555
|
+
status = str(cmd.get("status", "cancelled"))
|
|
556
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
557
|
+
break
|
|
558
|
+
cmd = await client.get_command(command_id)
|
|
559
|
+
status = str(cmd.get("status", ""))
|
|
560
|
+
if status == "pending_approval" and not prompted:
|
|
561
|
+
prompted = True
|
|
562
|
+
decision = resolve_approval_decision(
|
|
563
|
+
console, str(cmd.get("command_text", command)), str(cmd.get("safety_tier", "medium")),
|
|
564
|
+
approval_policy, interactive,
|
|
565
|
+
)
|
|
566
|
+
await client.approve_command(command_id, decision)
|
|
567
|
+
if status in TERMINAL_COMMAND_STATUSES:
|
|
568
|
+
break
|
|
569
|
+
try:
|
|
570
|
+
await asyncio.wait_for(interrupted.wait(), timeout=COMMAND_POLL_INTERVAL_SECONDS)
|
|
571
|
+
except asyncio.TimeoutError:
|
|
572
|
+
pass
|
|
573
|
+
finally:
|
|
574
|
+
uninstall_sigint()
|
|
575
|
+
|
|
576
|
+
stdout = str(cmd.get("stdout") or "")
|
|
577
|
+
stderr = str(cmd.get("stderr") or "")
|
|
578
|
+
exit_code = cmd.get("exit_code")
|
|
579
|
+
body = stdout.strip()
|
|
580
|
+
if stderr.strip():
|
|
581
|
+
body = (body + "\n" + stderr.strip()).strip()
|
|
582
|
+
ok = status == "completed" and exit_code == 0
|
|
583
|
+
if not body:
|
|
584
|
+
if ok:
|
|
585
|
+
body = "Command completed successfully with no output"
|
|
586
|
+
elif status == "denied":
|
|
587
|
+
body = "Command was rejected by the user"
|
|
588
|
+
elif status == "cancelled":
|
|
589
|
+
body = "Command was cancelled"
|
|
590
|
+
else:
|
|
591
|
+
body = f"Command failed with exit code {exit_code}"
|
|
592
|
+
console.print(Panel(body, title=f"{status} · exit {exit_code}", border_style="green" if ok else "red"))
|
|
593
|
+
|
|
594
|
+
if ok:
|
|
595
|
+
outcome = TaskOutcome(status="completed", summary=stdout)
|
|
596
|
+
else:
|
|
597
|
+
outcome = TaskOutcome(status=status or "failed", error=stderr or f"exit code {exit_code}")
|
|
598
|
+
local_state.finish_action(session_id, action.id, status=outcome.status, summary=f"exit={exit_code}")
|
|
599
|
+
local_state.checkpoint(session_id, reason=f"command_{outcome.status}", summary=f"exit={exit_code}")
|
|
600
|
+
return outcome
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
async def follow_session_logs(
|
|
604
|
+
client: RemoteAPIClient,
|
|
605
|
+
renderer: StreamRenderer,
|
|
606
|
+
console: Console,
|
|
607
|
+
*,
|
|
608
|
+
session_id: int,
|
|
609
|
+
from_event_id: int = 0,
|
|
610
|
+
) -> None:
|
|
611
|
+
interrupted, uninstall_sigint = _install_sigint_watcher()
|
|
612
|
+
|
|
613
|
+
async def consume() -> None:
|
|
614
|
+
async for event in client.stream_session(session_id, from_event_id):
|
|
615
|
+
sequence = event.get("stream_sequence")
|
|
616
|
+
if sequence is not None:
|
|
617
|
+
local_state.save_session_state(session_id, last_event_id=int(sequence))
|
|
618
|
+
renderer.handle_event(event)
|
|
619
|
+
|
|
620
|
+
consume_task = asyncio.ensure_future(consume())
|
|
621
|
+
interrupt_task = asyncio.ensure_future(interrupted.wait())
|
|
622
|
+
try:
|
|
623
|
+
done, _pending = await asyncio.wait({consume_task, interrupt_task}, return_when=asyncio.FIRST_COMPLETED)
|
|
624
|
+
if consume_task not in done:
|
|
625
|
+
consume_task.cancel()
|
|
626
|
+
try:
|
|
627
|
+
await consume_task
|
|
628
|
+
except asyncio.CancelledError:
|
|
629
|
+
pass
|
|
630
|
+
else:
|
|
631
|
+
await consume_task
|
|
632
|
+
finally:
|
|
633
|
+
interrupt_task.cancel()
|
|
634
|
+
uninstall_sigint()
|
|
635
|
+
renderer.finish()
|