@xingjisoft/neutrino 0.5.2 → 0.6.2

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.
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  /**
2
3
  * Headless conversation runner for the web UI.
3
4
  *
@@ -11,15 +12,18 @@
11
12
  * (snapshot, stream, pause, resume, message) work unchanged.
12
13
  */
13
14
  import { randomUUID } from "node:crypto";
14
- import { findModel, contextCompressionThreshold, intensityForModel, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadModels, loadPrompt, saveSession, } from "../config.js";
15
+ import { findModel, contextCompressionThreshold, modelContextWindow, sessionDir, intensityForModel, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadModels, loadAgentPrompt, normalizeAgentMode, saveSession, } from "../config.js";
15
16
  import { COMPACT_KEEP, planCompaction, transcriptFor, modelHistory, normalizeContext, } from "../compact.js";
17
+ import { TaskRuntime, TaskControlError, currentTask, linkSignals, taskLimits, taskBudgetScope, resumeTaskSnapshot } from "../llm/runtime.js";
18
+ import { prepareContext, outputAllowance, finalTaskReport } from "../llm/context.js";
19
+ import { FINISH_STEP_TOOL, FINISH_STEP_DEFINITION, finishPlanStep, preparePlan, settlePlan } from "../llm/plan.js";
16
20
  import { LLMClient, describeError } from "../llm/client.js";
17
21
  import { defaultProPreset, loadProModels, loadProPrompt, proMaxRecursionDepth, requireProPreset } from "../pro/config.js";
18
22
  import { resolveProTeam, ProPreflightError } from "../pro/preflight.js";
19
23
  import { ProAgentTeam, getProToolDefinitions } from "../pro/agents.js";
20
24
  import { advancePlan, buildPlannerInput, lastRequestText, needsPlan, parsePlan, planMessage, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
21
25
  import { DELEGATE_TOOL, executeTool, getSubAgentToolDefinitions, getToolDefinitions } from "../llm/tools.js";
22
- import { buildSubAgentTask, formatSubAgentResult, runSubAgent as runSubAgentTask, } from "../llm/subagent.js";
26
+ import { buildSubAgentTask, formatSubAgentResult, runSubAgent as runSubAgentTask, subAgentRounds, } from "../llm/subagent.js";
23
27
  import { assessToolCall } from "../llm/risk.js";
24
28
  import { ApprovalGate, captureApprovalContext, describeApprovalIntent } from "../llm/approval.js";
25
29
  import { SessionCheckpoint } from "../persistence.js";
@@ -45,7 +49,7 @@ export function newSessionId(now = new Date()) {
45
49
  * written to disk until the first message, so an abandoned new-chat dialog
46
50
  * cannot litter the history with empty transcripts.
47
51
  */
48
- export function createWebSession(cwd, selection, approvalMode, mode = "standard", proPreset) {
52
+ export function createWebSession(cwd, selection, approvalMode, mode = "standard", proPreset, agentMode = "standard") {
49
53
  const now = new Date();
50
54
  const source = {
51
55
  id: newSessionId(now),
@@ -59,6 +63,7 @@ export function createWebSession(cwd, selection, approvalMode, mode = "standard"
59
63
  messages: [],
60
64
  tokens: { prompt: 0, completion: 0 },
61
65
  approval_mode: normalizeApprovalMode(approvalMode ?? loadConfig(mode).approval_mode),
66
+ agent_mode: mode === "pro" ? "standard" : agentMode,
62
67
  ...(mode === "pro" ? { pro_preset: requireProPreset(proPreset ?? defaultProPreset()) } : {}),
63
68
  };
64
69
  return new WebSession(source, selection.model, mode);
@@ -161,7 +166,13 @@ export class WebSession {
161
166
  this.publishLive();
162
167
  return saved;
163
168
  });
169
+ taskRuntime = null;
170
+ lastTask;
171
+ taskOutcome = "completed";
164
172
  turnToken = 0;
173
+ agentMode = "standard";
174
+ activeAgentMode = "standard";
175
+ turnAbort = null;
165
176
  lastPromptTokens = 0;
166
177
  disposed = false;
167
178
  idleTimer = null;
@@ -171,6 +182,7 @@ export class WebSession {
171
182
  reviewing = null;
172
183
  constructor(source, model, mode = "standard") {
173
184
  this.mode = mode;
185
+ this.agentMode = mode === "pro" ? "standard" : normalizeAgentMode(source.agent_mode);
174
186
  if (mode === "pro")
175
187
  this.proPreset = requireProPreset(source.pro_preset ?? defaultProPreset());
176
188
  this.model = model;
@@ -184,6 +196,7 @@ export class WebSession {
184
196
  this.messages = source.messages.slice();
185
197
  this.context = normalizeContext(source.context, this.messages.filter((m) => m.role !== "system").length);
186
198
  this.plan = resumePlan(source.plan ?? null);
199
+ this.lastTask = resumeTaskSnapshot(source.task_run);
187
200
  this.tokens = {
188
201
  prompt: source.tokens?.prompt ?? 0,
189
202
  completion: source.tokens?.completion ?? 0,
@@ -204,6 +217,7 @@ export class WebSession {
204
217
  return;
205
218
  this.disposed = true;
206
219
  this.proAbort?.abort();
220
+ this.turnAbort?.abort();
207
221
  this.turnToken++;
208
222
  this.busy = false;
209
223
  if (this.pendingApproval) {
@@ -267,6 +281,9 @@ export class WebSession {
267
281
  model: this.model.name,
268
282
  intensity: this.intensity,
269
283
  approvalMode: this.approvalMode,
284
+ agentMode: this.agentMode,
285
+ activeAgentMode: this.busy ? this.activeAgentMode : this.agentMode,
286
+ task: this.taskRuntime?.snapshot() ?? this.lastTask,
270
287
  busy: this.busy,
271
288
  paused: this.paused,
272
289
  queued: this.queued,
@@ -316,6 +333,7 @@ export class WebSession {
316
333
  return;
317
334
  this.paused = true;
318
335
  this.proAbort?.abort();
336
+ this.turnAbort?.abort();
319
337
  this.turnToken++;
320
338
  // A queued decision would otherwise keep the promise (and the gate) alive.
321
339
  if (this.pendingApproval) {
@@ -328,11 +346,9 @@ export class WebSession {
328
346
  this.subagent = null;
329
347
  if (this.proState)
330
348
  this.proState.checking = false;
331
- if (this.mode === "pro") {
332
- this.reviewing = null;
333
- this.retrying = null;
334
- this.planning = false;
335
- }
349
+ this.reviewing = null;
350
+ this.retrying = null;
351
+ this.planning = false;
336
352
  stopPlan(this.plan);
337
353
  this.save();
338
354
  this.publishLive();
@@ -356,6 +372,14 @@ export class WebSession {
356
372
  this.publishLive();
357
373
  return true;
358
374
  }
375
+ liveSetAgentMode(mode) {
376
+ if (this.mode === "pro" || this.disposed)
377
+ return { ok: false, error: "this session cannot change agent mode" };
378
+ this.agentMode = mode;
379
+ this.save();
380
+ this.publishLive();
381
+ return { ok: true };
382
+ }
359
383
  liveSetApprovalMode(mode) {
360
384
  if (this.approvalMode === mode)
361
385
  return;
@@ -382,7 +406,10 @@ export class WebSession {
382
406
  this.proAbort?.abort();
383
407
  this.proTeam = null;
384
408
  this.proState = undefined;
409
+ this.turnAbort?.abort();
385
410
  this.turnToken++;
411
+ this.taskRuntime = null;
412
+ this.lastTask = undefined;
386
413
  if (this.pendingApproval) {
387
414
  const pending = this.pendingApproval;
388
415
  this.pendingApproval = null;
@@ -430,9 +457,24 @@ export class WebSession {
430
457
  this.startTurn();
431
458
  }
432
459
  startTurn() {
460
+ if (this.taskRuntime?.settling) {
461
+ const previous = this.taskRuntime;
462
+ this.queued = lastRequestText(this.messages);
463
+ void previous.drain().then(() => {
464
+ if (this.taskRuntime === previous && this.queued && !this.paused) {
465
+ this.queued = null;
466
+ this.startTurn();
467
+ }
468
+ });
469
+ this.publishLive();
470
+ return;
471
+ }
433
472
  const turnMessages = this.messages.slice();
434
473
  const approvalContext = captureApprovalContext(turnMessages);
435
474
  this.busy = true;
475
+ this.activeAgentMode = this.agentMode;
476
+ this.turnAbort?.abort();
477
+ this.turnAbort = new AbortController();
436
478
  const token = ++this.turnToken;
437
479
  this.streamingContent = "";
438
480
  this.publishLive();
@@ -449,16 +491,59 @@ export class WebSession {
449
491
  * pass, exactly as it did before planning existed.
450
492
  */
451
493
  async runTurn(token, turnMessages, approvalContext) {
494
+ const runtime = new TaskRuntime({
495
+ request: approvalContext.prompt, priorUserRequests: approvalContext.priorUserRequests,
496
+ cwd: this.dir, signal: this.turnAbort?.signal, limits: taskLimits(loadConfig(this.mode)),
497
+ budgetScope: taskBudgetScope(loadConfig(this.mode)),
498
+ journalDir: path.join(sessionDir(this.mode), "tasks", path.basename(this.sessionId || "unsaved")),
499
+ });
500
+ this.taskRuntime = runtime;
501
+ this.taskOutcome = "completed";
502
+ let failed = false;
503
+ try {
504
+ await runtime.run(() => this.runTaskTurn(token, turnMessages, approvalContext));
505
+ }
506
+ catch (error) {
507
+ failed = true;
508
+ throw error;
509
+ }
510
+ finally {
511
+ await runtime.drain();
512
+ if (runtime.signal.aborted)
513
+ settlePlan(runtime.plan ?? null, true);
514
+ runtime.finish(runtime.signal.aborted ? "cancelled" : failed ? "failed" :
515
+ runtime.plan?.steps.some(step => step.status === "failed") ? "failed" :
516
+ runtime.plan?.steps.some(step => !["done", "skipped"].includes(step.status)) ? "partial" : this.taskOutcome);
517
+ if (this.taskRuntime === runtime && !this.disposed) {
518
+ if (runtime.signal.aborted && token === this.turnToken && this.busy && !this.paused) {
519
+ if (this.taskOutcome !== "cancelled")
520
+ this.messages.push({ role: "assistant", content: `[Task stopped: ${String(runtime.signal.reason)}]\n${runtime.partialReport()}` });
521
+ this.streamingContent = null;
522
+ this.onTurnFinished();
523
+ }
524
+ if (this.taskRuntime === runtime) {
525
+ this.lastTask = runtime.snapshot();
526
+ this.save();
527
+ this.publishLive();
528
+ }
529
+ }
530
+ }
531
+ }
532
+ async runTaskTurn(token, turnMessages, approvalContext) {
452
533
  if (this.mode === "pro")
453
534
  return this.runProTurn(token, turnMessages, approvalContext);
454
535
  await this.autoCompress(token);
455
536
  if (token !== this.turnToken || this.paused)
456
537
  return;
457
- const plan = await this.makePlan(token);
538
+ if (this.activeAgentMode !== "standard")
539
+ this.plan = null;
540
+ const plan = this.activeAgentMode === "standard" ? await this.makePlan(token) : null;
458
541
  if (token !== this.turnToken || this.paused)
459
542
  return;
460
543
  if (plan) {
461
544
  this.plan = plan;
545
+ if (currentTask())
546
+ currentTask().runtime.plan = plan;
462
547
  this.messages.push({ role: "assistant", content: planMessage(plan) });
463
548
  this.save();
464
549
  this.publishLive();
@@ -470,7 +555,7 @@ export class WebSession {
470
555
  const finished = await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages), renderStepDirective(template, plan, i)), token, approvalContext);
471
556
  if (token !== this.turnToken || this.paused)
472
557
  return;
473
- this.setStep(i, finished ? "done" : "failed");
558
+ this.setStep(i, currentTask()?.signal.aborted ? "cancelled" : finished ? "done" : "failed");
474
559
  if (!finished)
475
560
  break;
476
561
  }
@@ -484,7 +569,8 @@ export class WebSession {
484
569
  }
485
570
  /** Freeze one resolved team per task, plan first, then give the whole brief to main. */
486
571
  async runProTurn(token, turnMessages, approvalContext) {
487
- const cancelled = () => token !== this.turnToken || this.paused || this.disposed;
572
+ const taskSignal = currentTask()?.signal;
573
+ const cancelled = () => token !== this.turnToken || this.paused || this.disposed || taskSignal?.aborted === true;
488
574
  const controller = new AbortController();
489
575
  this.proAbort = controller;
490
576
  this.proTeam = null;
@@ -509,6 +595,7 @@ export class WebSession {
509
595
  this.proState.fallbacks = resolved.fallbacks;
510
596
  this.proState.checking = false;
511
597
  const team = new ProAgentTeam({
598
+ runtime: currentTask()?.runtime, request: approvalContext.prompt,
512
599
  models: resolved.models, maxDepth: proMaxRecursionDepth(), cwd: this.dir, cancelled,
513
600
  approve: (name, args) => this.requestApproval(name, args, {
514
601
  ...approvalContext, context: captureApprovalContext(this.turnView(turnMessages)).context,
@@ -537,48 +624,44 @@ export class WebSession {
537
624
  this.publishLive();
538
625
  this.planning = true;
539
626
  this.publishLive();
540
- const planning = await team.call("plan_model", buildPlannerInput({
627
+ const planningResult = await team.callResult("plan_model", buildPlannerInput({
541
628
  request: approvalContext.prompt, cwd: this.dir, transcript: planTranscript(turnMessages),
542
629
  }), "Plan the task before execution. Do not modify files or execute commands.", 0, true);
630
+ const planning = planningResult.report;
543
631
  if (cancelled())
544
632
  return;
545
633
  this.planning = false;
546
634
  this.subagent = null;
547
635
  this.retrying = null;
548
- if (!planning.trim() || /\[Error:|\[Sub-agent .*without.*report/.test(planning)) {
549
- throw new Error(`Pro planning failed: ${planning}`);
550
- }
551
- this.plan = parsePlan(planning);
552
- if (!this.plan) {
553
- // Standard planning skips single actions; Pro plans every task.
554
- try {
555
- const single = JSON.parse(planning.trim().replace(/^```(?:json)?\s*|\s*```$/g, ""));
556
- if (single.steps?.length === 1 && typeof single.steps[0].title === "string" && single.steps[0].title.trim()) {
557
- this.plan = { goal: String(single.goal ?? "").slice(0, 240), steps: [{
558
- title: single.steps[0].title.slice(0, 160), detail: String(single.steps[0].detail ?? "").slice(0, 400), status: "pending",
559
- }] };
560
- }
561
- }
562
- catch { /* A useful prose plan can still guide the main agent. */ }
636
+ if (planningResult.status !== "completed" || !planning.trim()) {
637
+ throw new Error(`Pro planning failed: ${planningResult.error?.message ?? planning}`);
563
638
  }
639
+ this.plan = parsePlan(planning, 1);
640
+ if (!this.plan)
641
+ throw new Error("Pro planning failed: no usable plan steps were returned");
564
642
  await this.autoCompress(token);
565
643
  if (cancelled())
566
644
  return;
645
+ if (this.plan) {
646
+ preparePlan(this.plan);
647
+ if (currentTask())
648
+ currentTask().runtime.plan = this.plan;
649
+ }
567
650
  this.messages.push({ role: "assistant", content: this.plan ? planMessage(this.plan) : planning });
568
651
  this.save();
569
652
  this.publishLive();
570
- const directive = `The planning agent produced this plan for the current user request:\n${planning}\n\n` +
571
- "Execute the complete task, using the appropriate professional agents for reasoning, frontend, backend and modeling work. Verify the result and report the outcome.";
653
+ const directive = `The planning agent produced this plan for the current user request:\n${JSON.stringify(this.plan ?? planning)}\n\n` +
654
+ "Execute the complete task using the appropriate professional agents. Own integration and verification. " +
655
+ "Call finish_step for each accepted step using its step_id and actual evidence IDs, existing artifact paths, or the final text deliverable. " +
656
+ "Record blocked or failed steps explicitly. Ending a reply never completes plan steps.";
572
657
  if (this.plan?.steps.length)
573
658
  this.setStep(0, "active");
574
659
  const finished = await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages), directive), token, approvalContext);
575
660
  if (cancelled())
576
661
  return;
577
662
  if (this.plan) {
578
- if (finished)
579
- for (let i = 0; i < this.plan.steps.length; i++)
580
- this.setStep(i, "done");
581
- else
663
+ settlePlan(this.plan, currentTask()?.signal.aborted === true);
664
+ if (!finished && this.taskOutcome !== "partial" && this.plan.steps[0]?.status === "blocked")
582
665
  this.setStep(0, "failed");
583
666
  }
584
667
  }
@@ -660,10 +743,11 @@ export class WebSession {
660
743
  return null;
661
744
  }
662
745
  finally {
663
- this.planning = false;
664
- this.retrying = null;
665
- if (token === this.turnToken)
746
+ if (token === this.turnToken) {
747
+ this.planning = false;
748
+ this.retrying = null;
666
749
  this.publishLive();
750
+ }
667
751
  }
668
752
  }
669
753
  /** Ends a turn whose floating promise rejected, instead of crashing the app. */
@@ -686,9 +770,10 @@ export class WebSession {
686
770
  buildApiMessages(messages = this.messages, directive = "") {
687
771
  // A step directive extends the system prompt: the plan is an instruction, not
688
772
  // something the user just said.
689
- const prompt = this.mode === "pro" ? loadProPrompt("main_model") : loadPrompt();
773
+ const prompt = this.mode === "pro" ? loadProPrompt("main_model") : loadAgentPrompt(this.activeAgentMode);
690
774
  const api = [
691
- { role: "system", content: directive ? `${prompt}\n\n${directive}` : prompt },
775
+ { role: "system", content: (directive ? `${prompt}\n\n${directive}` : prompt) +
776
+ (currentTask() ? `\n\n${currentTask().runtime.instructions()}` : "") },
692
777
  ];
693
778
  for (const m of modelHistory(messages, this.context)) {
694
779
  if (m.role === "user" || m.role === "assistant") {
@@ -706,8 +791,12 @@ export class WebSession {
706
791
  * the caller owns the turn's end because a planned turn runs this per step.
707
792
  */
708
793
  async agentLoop(apiMessages, token, approvalContext) {
709
- const tools = this.mode === "pro" ? getProToolDefinitions() : getToolDefinitions();
794
+ const tools = this.mode === "pro" ? [...getProToolDefinitions(), FINISH_STEP_DEFINITION] : [
795
+ ...getToolDefinitions(),
796
+ ...(this.activeAgentMode === "ptc" ? [(await import("../llm/codemode.js")).getCodeToolDefinition()] : []),
797
+ ];
710
798
  const team = this.proTeam;
799
+ let emptyReplies = 0;
711
800
  try {
712
801
  while (true) {
713
802
  if (token !== this.turnToken || this.paused)
@@ -715,11 +804,19 @@ export class WebSession {
715
804
  // Match the terminal: long tasks can keep using tools until the model
716
805
  // finishes, while older tool results are trimmed near the context limit.
717
806
  apiMessages = this.trimApiMessages(apiMessages);
807
+ if (currentTask())
808
+ await prepareContext({
809
+ messages: apiMessages, tools, capacity: team?.contextWindow("main_model") ?? modelContextWindow(this.provider, this.model, this.mode),
810
+ createClient: () => team?.client("main_model") ?? new LLMClient(this.model, this.provider, this.intensity),
811
+ stream: { signal: currentTask().runtime.signal, cancelled: () => token !== this.turnToken || this.paused },
812
+ onUsage: usage => { this.tokens.prompt += usage.prompt; this.tokens.completion += usage.completion; },
813
+ });
718
814
  const client = team ? team.client("main_model") : new LLMClient(this.model, this.provider, this.intensity);
719
815
  let text = "";
720
816
  this.streamingContent = "";
721
817
  this.publishLive();
722
818
  for await (const chunk of client.streamTurn(apiMessages, tools, {
819
+ maxOutputTokens: outputAllowance(team?.contextWindow("main_model") ?? modelContextWindow(this.provider, this.model, this.mode)),
723
820
  onRetry: (notice) => {
724
821
  this.retrying = notice;
725
822
  this.publishLive();
@@ -742,6 +839,30 @@ export class WebSession {
742
839
  this.tokens.completion += client.usage.completion;
743
840
  this.lastPromptTokens = client.usage.prompt;
744
841
  }
842
+ if (currentTask()?.signal.aborted || client.outcome?.status === "cancelled") {
843
+ this.taskOutcome = "cancelled";
844
+ this.streamingContent = null;
845
+ this.messages.push({ role: "assistant", content: `[Task stopped: ${String(currentTask()?.signal.reason ?? "Task cancelled")}]\n${currentTask()?.runtime.partialReport() ?? ""}` });
846
+ return false;
847
+ }
848
+ if (client.outcome?.error?.code.endsWith("_budget") && currentTask()) {
849
+ const report = await finalTaskReport(currentTask().runtime, () => team?.client("main_model") ?? new LLMClient(this.model, this.provider, this.intensity), usage => { this.tokens.prompt += usage.prompt; this.tokens.completion += usage.completion; }, apiMessages);
850
+ if (token !== this.turnToken || this.paused)
851
+ return false;
852
+ this.taskOutcome = "partial";
853
+ this.streamingContent = null;
854
+ this.messages.push({ role: "assistant", content: report });
855
+ return false;
856
+ }
857
+ if (client.outcome?.status === "failed") {
858
+ const recorded = currentTask()?.runtime.evidence.length ? currentTask().runtime.partialReport() : "";
859
+ const message = client.outcome.error?.message ?? "Model request failed";
860
+ this.taskOutcome = recorded ? "partial" : "failed";
861
+ this.streamingContent = null;
862
+ this.messages.push({ role: "assistant", content: text +
863
+ (text.includes(message) ? "" : `\n[Error: ${message}]`) + (recorded ? `\n\n${recorded}` : "") });
864
+ return false;
865
+ }
745
866
  const toolCalls = client.toolCalls;
746
867
  if (text.trim()) {
747
868
  this.messages.push({ role: "assistant", content: text });
@@ -770,11 +891,17 @@ export class WebSession {
770
891
  this.save();
771
892
  this.publishLive();
772
893
  if (toolCalls.length === 0) {
773
- if (this.mode === "pro" && !text.trim()) {
894
+ if (!text.trim() && emptyReplies++ === 0)
895
+ continue;
896
+ if (!text.trim()) {
897
+ this.taskOutcome = "failed";
774
898
  this.messages.push({ role: "assistant", content: "[Error: Pro main model returned an empty completion.]" });
775
899
  return false;
776
900
  }
777
- return !/\[Error:/.test(text);
901
+ const success = client.outcome ? client.outcome.status === "completed" : !/\[Error:/.test(text);
902
+ if (!success)
903
+ this.taskOutcome = "failed";
904
+ return success;
778
905
  }
779
906
  for (const call of toolCalls) {
780
907
  if (token !== this.turnToken || this.paused)
@@ -804,6 +931,7 @@ export class WebSession {
804
931
  describeApprovalIntent(approvalContext) +
805
932
  "Do not retry this call unchanged; ask the user how to proceed.";
806
933
  toolMsg.result = denied;
934
+ currentTask()?.runtime.recordTool(call.name, args, denied, "denied");
807
935
  apiMessages.push({ role: "tool", content: denied, tool_call_id: call.id });
808
936
  this.save();
809
937
  this.publishLive();
@@ -814,6 +942,13 @@ export class WebSession {
814
942
  result = await this.runTool(call.name, args, toolMsg, token, apiMessages, approvalContext);
815
943
  }
816
944
  catch (err) {
945
+ if (err instanceof TaskControlError && err.code.endsWith("_budget")) {
946
+ toolMsg.result = `[Not executed: ${describeError(err)}]`;
947
+ this.save();
948
+ throw err;
949
+ }
950
+ if (token === this.turnToken)
951
+ this.taskOutcome = "failed";
817
952
  result = `Error: ${err instanceof Error ? err.message : String(err)}`;
818
953
  }
819
954
  if (this.disposed)
@@ -822,7 +957,8 @@ export class WebSession {
822
957
  this.save();
823
958
  if (token !== this.turnToken || this.paused)
824
959
  return false;
825
- apiMessages.push({ role: "tool", content: result, tool_call_id: call.id });
960
+ apiMessages.push({ role: "tool", content: toolMsg.evidenceId
961
+ ? `${result}\n[Task evidence: ${toolMsg.evidenceId}; status=${toolMsg.executionStatus}]` : result, tool_call_id: call.id });
826
962
  this.publishLive();
827
963
  }
828
964
  this.save();
@@ -831,6 +967,15 @@ export class WebSession {
831
967
  catch (err) {
832
968
  if (token !== this.turnToken)
833
969
  return false;
970
+ if (err instanceof TaskControlError && err.code.endsWith("_budget") && currentTask()) {
971
+ const report = await finalTaskReport(currentTask().runtime, () => team?.client("main_model") ?? new LLMClient(this.model, this.provider, this.intensity), usage => { this.tokens.prompt += usage.prompt; this.tokens.completion += usage.completion; }, apiMessages);
972
+ if (token !== this.turnToken || this.paused)
973
+ return false;
974
+ this.taskOutcome = "partial";
975
+ this.streamingContent = null;
976
+ this.messages.push({ role: "assistant", content: report });
977
+ return false;
978
+ }
834
979
  if (this.streamingContent) {
835
980
  this.messages.push({ role: "assistant", content: this.streamingContent });
836
981
  }
@@ -856,15 +1001,26 @@ export class WebSession {
856
1001
  }
857
1002
  // ---- approval gate ---------------------------------------------------
858
1003
  /** Classify a tool call and, when it is risky, wait for a decision. */
859
- async requestApproval(tool, args, context = captureApprovalContext(this.messages)) {
1004
+ async requestApproval(tool, args, context = captureApprovalContext(this.messages), signal = currentTask()?.runtime.signal ?? this.turnAbort?.signal) {
1005
+ const linked = linkSignals(signal, currentTask()?.signal);
1006
+ signal = linked.signal;
1007
+ try {
1008
+ currentTask()?.runtime.assertTool(tool, args);
1009
+ }
1010
+ catch (error) {
1011
+ linked.dispose();
1012
+ return { approved: false, source: "input", reason: error instanceof Error ? error.message : String(error) };
1013
+ }
860
1014
  const risk = assessToolCall(tool, args, this.dir);
861
- if (!risk)
1015
+ if (!risk) {
1016
+ linked.dispose();
862
1017
  return { approved: true, source: "prompt", reason: "routine call" };
1018
+ }
863
1019
  const approvalToken = this.turnToken;
864
1020
  const team = this.proTeam;
865
1021
  const gate = new ApprovalGate({
866
1022
  askHuman: () => new Promise((resolve) => {
867
- if (this.mode === "pro" && (approvalToken !== this.turnToken || this.paused || this.disposed)) {
1023
+ if (approvalToken !== this.turnToken || this.paused || this.disposed || signal?.aborted === true) {
868
1024
  resolve(false);
869
1025
  return;
870
1026
  }
@@ -875,12 +1031,12 @@ export class WebSession {
875
1031
  ...(this.mode === "pro" && team ? {
876
1032
  createReviewerClient: () => team.client("confirm_model"),
877
1033
  reviewerSystemPrompt: () => loadProPrompt("confirm_model"),
878
- cancelled: () => approvalToken !== this.turnToken || this.paused || this.disposed,
879
1034
  } : {}),
1035
+ cancelled: () => approvalToken !== this.turnToken || this.paused || this.disposed || signal?.aborted === true,
880
1036
  autoApprovalEnabled: () => this.approvalMode === "auto",
881
1037
  extremeAutoApprovalEnabled: () => loadConfig(this.mode).auto_approve_extreme_risk === true,
882
1038
  onReview: (info) => {
883
- if (this.mode === "pro" && (approvalToken !== this.turnToken || this.paused || this.disposed))
1039
+ if (approvalToken !== this.turnToken || this.paused || this.disposed || signal?.aborted === true)
884
1040
  return;
885
1041
  this.reviewing = info
886
1042
  ? {
@@ -891,7 +1047,22 @@ export class WebSession {
891
1047
  this.publishLive();
892
1048
  },
893
1049
  });
894
- return gate.decide({ tool, args, cwd: this.dir, ...context, risk });
1050
+ const abortApproval = () => {
1051
+ const pending = this.pendingApproval;
1052
+ if (pending?.args !== args)
1053
+ return;
1054
+ this.pendingApproval = null;
1055
+ pending.resolve(false);
1056
+ this.publishLive();
1057
+ };
1058
+ signal.addEventListener("abort", abortApproval, { once: true });
1059
+ try {
1060
+ return await gate.decide({ tool, args, cwd: this.dir, ...context, risk });
1061
+ }
1062
+ finally {
1063
+ signal.removeEventListener("abort", abortApproval);
1064
+ linked.dispose();
1065
+ }
895
1066
  }
896
1067
  /** Reviewer model: `approval_model` when set, otherwise the chat model. */
897
1068
  reviewerModel() {
@@ -905,63 +1076,172 @@ export class WebSession {
905
1076
  }
906
1077
  return { model: this.model, provider: this.provider, intensity: this.intensity };
907
1078
  }
1079
+ async runProgram(args, token, apiMessages, approvalContext) {
1080
+ if (this.activeAgentMode !== "ptc")
1081
+ return "[Error: execute_ts requires PTC mode]";
1082
+ const { executeCodeProgram, ProgramToolDenied } = await import("../llm/codemode.js");
1083
+ const signal = currentTask()?.runtime.signal ?? this.turnAbort?.signal;
1084
+ const cancelled = () => token !== this.turnToken || this.paused || signal?.aborted;
1085
+ const result = await executeCodeProgram(args, {
1086
+ signal,
1087
+ onPlan: plan => {
1088
+ if (cancelled())
1089
+ return;
1090
+ this.plan = plan;
1091
+ if (currentTask())
1092
+ currentTask().runtime.plan = plan;
1093
+ this.save();
1094
+ this.publishLive();
1095
+ },
1096
+ onStep: (index, status) => { if (!cancelled())
1097
+ this.setStep(index, status); },
1098
+ call: async (name, callArgs, programSignal) => {
1099
+ if (cancelled() || programSignal?.aborted)
1100
+ throw new Error("Execution cancelled");
1101
+ const message = { role: "tool", name, arguments: callArgs, result: "…" };
1102
+ this.messages.push(message);
1103
+ this.save();
1104
+ this.publishLive();
1105
+ const stopApproval = () => {
1106
+ if (this.pendingApproval?.args !== callArgs)
1107
+ return;
1108
+ const pending = this.pendingApproval;
1109
+ this.pendingApproval = null;
1110
+ pending.resolve(false);
1111
+ this.publishLive();
1112
+ };
1113
+ programSignal?.addEventListener("abort", stopApproval, { once: true });
1114
+ try {
1115
+ const decision = await this.requestApproval(name, callArgs, {
1116
+ ...approvalContext, context: captureApprovalContext(apiMessages).context,
1117
+ }, programSignal);
1118
+ if (cancelled() || programSignal?.aborted)
1119
+ throw new Error("Execution cancelled");
1120
+ if (!decision.approved) {
1121
+ message.result = `[Not executed: ${decision.reason}]`;
1122
+ throw new ProgramToolDenied(`Approval denied: ${decision.reason}. Do not retry or work around this denial.`);
1123
+ }
1124
+ const value = await this.runTool(name, callArgs, message, token, apiMessages, approvalContext, programSignal);
1125
+ message.result = value;
1126
+ if (cancelled() || programSignal?.aborted)
1127
+ throw new Error("Execution cancelled");
1128
+ return value;
1129
+ }
1130
+ catch (error) {
1131
+ if (message.result === "…")
1132
+ message.result = `[Error: ${error instanceof Error ? error.message : String(error)}]`;
1133
+ throw error;
1134
+ }
1135
+ finally {
1136
+ programSignal?.removeEventListener("abort", stopApproval);
1137
+ if (this.messages.includes(message)) {
1138
+ this.save();
1139
+ this.publishLive();
1140
+ }
1141
+ }
1142
+ },
1143
+ });
1144
+ if (!cancelled()) {
1145
+ this.save();
1146
+ this.publishLive();
1147
+ }
1148
+ return result;
1149
+ }
908
1150
  // ---- sub-agents ------------------------------------------------------
909
1151
  /**
910
1152
  * Run one approved tool call. `delegate` runs a child agent instead of
911
1153
  * touching the filesystem, so it is handled here, where the session's model,
912
1154
  * approval gate and abort signal live.
913
1155
  */
914
- async runTool(name, args, toolMsg, token, apiMessages, approvalContext) {
1156
+ async runTool(name, args, toolMsg, token, apiMessages, approvalContext, executionSignal) {
1157
+ const runtime = currentTask()?.runtime;
1158
+ const execute = () => this.dispatchTool(name, args, toolMsg, token, apiMessages, approvalContext, executionSignal);
1159
+ if (!runtime)
1160
+ return execute();
1161
+ const result = await runtime.tool(name, args, execute);
1162
+ const evidence = runtime.evidence.at(-1);
1163
+ if (evidence && this.mode === "pro") {
1164
+ toolMsg.evidenceId = evidence.id;
1165
+ toolMsg.executionStatus = evidence.status;
1166
+ }
1167
+ return result;
1168
+ }
1169
+ async dispatchTool(name, args, toolMsg, token, apiMessages, approvalContext, signal = currentTask()?.runtime.signal ?? this.turnAbort?.signal) {
1170
+ if (name === FINISH_STEP_TOOL && currentTask()) {
1171
+ const result = finishPlanStep(this.plan, args, currentTask().runtime);
1172
+ this.save();
1173
+ this.publishLive();
1174
+ return result;
1175
+ }
915
1176
  if (this.mode === "pro" && (name === "call_pro_agent" || name === DELEGATE_TOOL)) {
916
1177
  const team = this.proTeam;
917
1178
  if (!team)
918
1179
  return "[Error: Pro models have not passed preflight]";
919
1180
  return team.call(String(args.role ?? "backend_output_model"), String(args.task ?? ""), `${typeof args.context === "string" ? args.context : ""}\n\nCurrent user request: ${approvalContext.prompt}\n` +
920
- captureApprovalContext(apiMessages).context, 1);
1181
+ captureApprovalContext(apiMessages).context, 1, args.read_only === true, Array.isArray(args.write_paths) ? args.write_paths.filter((p) => typeof p === "string") : undefined);
921
1182
  }
1183
+ if (name === "execute_ts")
1184
+ return this.runProgram(args, token, apiMessages, approvalContext);
922
1185
  if (name !== DELEGATE_TOOL)
923
- return executeTool(name, args, this.dir);
924
- return this.delegate(args, toolMsg, token, apiMessages, approvalContext);
1186
+ return executeTool(name, args, this.dir, signal);
1187
+ return this.delegate(args, toolMsg, token, apiMessages, approvalContext, signal);
925
1188
  }
926
1189
  /**
927
1190
  * Run a sub-agent for one `delegate` call and return its report. The child
928
1191
  * works in its own transcript; only the report reaches this conversation, and
929
1192
  * its risky calls go through the same approval gate as the parent turn.
930
1193
  */
931
- async delegate(args, toolMsg, token, apiMessages, approvalContext) {
1194
+ async delegate(args, toolMsg, token, apiMessages, approvalContext, executionSignal) {
932
1195
  const task = String(args.task ?? "").trim();
933
1196
  if (!task)
934
1197
  return "[Error: delegate needs a task: what should the sub-agent do?]";
935
1198
  const context = typeof args.context === "string" ? args.context.trim() : "";
936
1199
  const approval = { ...approvalContext, context: captureApprovalContext(apiMessages).context };
1200
+ const maxRounds = subAgentRounds(loadConfig(this.mode).subagent_max_rounds);
1201
+ const runtime = currentTask()?.runtime;
1202
+ const signal = executionSignal ?? runtime?.signal ?? this.turnAbort?.signal;
1203
+ const stopped = () => token !== this.turnToken || this.paused || signal?.aborted === true;
937
1204
  const run = await runSubAgentTask({
1205
+ runtime, signal,
1206
+ readOnly: args.read_only === true,
1207
+ writePaths: Array.isArray(args.write_paths) ? args.write_paths.filter((p) => typeof p === "string") : undefined,
1208
+ contextWindow: modelContextWindow(this.provider, this.model, this.mode),
938
1209
  createClient: () => new LLMClient(this.model, this.provider, this.intensity),
939
1210
  system: loadSubAgentPrompt(),
940
- task: buildSubAgentTask({ task, context, cwd: this.dir }),
1211
+ task: buildSubAgentTask({ task, context, cwd: this.dir, rounds: maxRounds }),
941
1212
  tools: SUB_AGENT_TOOLS,
942
- approve: (name, callArgs) => this.requestApproval(name, callArgs, approval),
1213
+ maxRounds,
1214
+ approve: (name, callArgs) => this.requestApproval(name, callArgs, approval, signal),
943
1215
  execute: (name, callArgs) => name === DELEGATE_TOOL
944
1216
  ? Promise.resolve("[Error: sub-agents cannot delegate further]")
945
- : executeTool(name, callArgs, this.dir),
1217
+ : executeTool(name, callArgs, this.dir, signal),
946
1218
  onProgress: (label) => {
1219
+ if (stopped())
1220
+ return;
947
1221
  this.subagent = label;
948
1222
  toolMsg.result = `… ${label}`;
949
1223
  this.publishLive();
950
1224
  },
951
1225
  onUsage: (usage) => {
1226
+ if (stopped())
1227
+ return;
952
1228
  this.tokens.prompt += usage.prompt;
953
1229
  this.tokens.completion += usage.completion;
954
1230
  },
955
1231
  onRetry: (notice) => {
1232
+ if (stopped())
1233
+ return;
956
1234
  this.retrying = notice;
957
1235
  this.publishLive();
958
1236
  },
959
- cancelled: () => token !== this.turnToken || this.paused || this.disposed,
1237
+ cancelled: stopped,
960
1238
  });
961
- this.subagent = null;
962
- this.retrying = null;
963
- toolMsg.result = TOOL_PENDING;
964
- this.publishLive();
1239
+ if (!stopped()) {
1240
+ this.subagent = null;
1241
+ this.retrying = null;
1242
+ toolMsg.result = TOOL_PENDING;
1243
+ this.publishLive();
1244
+ }
965
1245
  return formatSubAgentResult(run);
966
1246
  }
967
1247
  // ---- compression -----------------------------------------------------
@@ -1067,7 +1347,7 @@ export class WebSession {
1067
1347
  if (chat.length === 0)
1068
1348
  return true;
1069
1349
  try {
1070
- this.sessionId = saveSession(chat, this.model.name, this.provider, this.intensity, this.dir, this.sessionId, this.tokens, this.context, this.approvalMode, this.plan, this.mode, this.proPreset ?? undefined);
1350
+ this.sessionId = saveSession(chat, this.model.name, this.provider, this.intensity, this.dir, this.sessionId, this.tokens, this.context, this.approvalMode, this.plan, this.mode, this.proPreset ?? undefined, this.agentMode, this.taskRuntime?.snapshot() ?? this.lastTask);
1071
1351
  this.saveError = null;
1072
1352
  return true;
1073
1353
  }