@xingjisoft/neutrino 0.5.2 → 0.6.0

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, currentTask, linkSignals, taskLimits, 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,58 @@ 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
+ journalDir: path.join(sessionDir(this.mode), "tasks", path.basename(this.sessionId || "unsaved")),
498
+ });
499
+ this.taskRuntime = runtime;
500
+ this.taskOutcome = "completed";
501
+ let failed = false;
502
+ try {
503
+ await runtime.run(() => this.runTaskTurn(token, turnMessages, approvalContext));
504
+ }
505
+ catch (error) {
506
+ failed = true;
507
+ throw error;
508
+ }
509
+ finally {
510
+ await runtime.drain();
511
+ if (runtime.signal.aborted)
512
+ settlePlan(runtime.plan ?? null, true);
513
+ runtime.finish(runtime.signal.aborted ? "cancelled" : failed ? "failed" :
514
+ runtime.plan?.steps.some(step => step.status === "failed") ? "failed" :
515
+ runtime.plan?.steps.some(step => !["done", "skipped"].includes(step.status)) ? "partial" : this.taskOutcome);
516
+ if (this.taskRuntime === runtime && !this.disposed) {
517
+ if (runtime.signal.aborted && token === this.turnToken && this.busy && !this.paused) {
518
+ if (this.taskOutcome !== "cancelled")
519
+ this.messages.push({ role: "assistant", content: `[Task stopped: ${String(runtime.signal.reason)}]\n${runtime.partialReport()}` });
520
+ this.streamingContent = null;
521
+ this.onTurnFinished();
522
+ }
523
+ if (this.taskRuntime === runtime) {
524
+ this.lastTask = runtime.snapshot();
525
+ this.save();
526
+ this.publishLive();
527
+ }
528
+ }
529
+ }
530
+ }
531
+ async runTaskTurn(token, turnMessages, approvalContext) {
452
532
  if (this.mode === "pro")
453
533
  return this.runProTurn(token, turnMessages, approvalContext);
454
534
  await this.autoCompress(token);
455
535
  if (token !== this.turnToken || this.paused)
456
536
  return;
457
- const plan = await this.makePlan(token);
537
+ if (this.activeAgentMode !== "standard")
538
+ this.plan = null;
539
+ const plan = this.activeAgentMode === "standard" ? await this.makePlan(token) : null;
458
540
  if (token !== this.turnToken || this.paused)
459
541
  return;
460
542
  if (plan) {
461
543
  this.plan = plan;
544
+ if (currentTask())
545
+ currentTask().runtime.plan = plan;
462
546
  this.messages.push({ role: "assistant", content: planMessage(plan) });
463
547
  this.save();
464
548
  this.publishLive();
@@ -470,7 +554,7 @@ export class WebSession {
470
554
  const finished = await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages), renderStepDirective(template, plan, i)), token, approvalContext);
471
555
  if (token !== this.turnToken || this.paused)
472
556
  return;
473
- this.setStep(i, finished ? "done" : "failed");
557
+ this.setStep(i, currentTask()?.signal.aborted ? "cancelled" : finished ? "done" : "failed");
474
558
  if (!finished)
475
559
  break;
476
560
  }
@@ -484,7 +568,8 @@ export class WebSession {
484
568
  }
485
569
  /** Freeze one resolved team per task, plan first, then give the whole brief to main. */
486
570
  async runProTurn(token, turnMessages, approvalContext) {
487
- const cancelled = () => token !== this.turnToken || this.paused || this.disposed;
571
+ const taskSignal = currentTask()?.signal;
572
+ const cancelled = () => token !== this.turnToken || this.paused || this.disposed || taskSignal?.aborted === true;
488
573
  const controller = new AbortController();
489
574
  this.proAbort = controller;
490
575
  this.proTeam = null;
@@ -509,6 +594,7 @@ export class WebSession {
509
594
  this.proState.fallbacks = resolved.fallbacks;
510
595
  this.proState.checking = false;
511
596
  const team = new ProAgentTeam({
597
+ runtime: currentTask()?.runtime, request: approvalContext.prompt,
512
598
  models: resolved.models, maxDepth: proMaxRecursionDepth(), cwd: this.dir, cancelled,
513
599
  approve: (name, args) => this.requestApproval(name, args, {
514
600
  ...approvalContext, context: captureApprovalContext(this.turnView(turnMessages)).context,
@@ -537,48 +623,44 @@ export class WebSession {
537
623
  this.publishLive();
538
624
  this.planning = true;
539
625
  this.publishLive();
540
- const planning = await team.call("plan_model", buildPlannerInput({
626
+ const planningResult = await team.callResult("plan_model", buildPlannerInput({
541
627
  request: approvalContext.prompt, cwd: this.dir, transcript: planTranscript(turnMessages),
542
628
  }), "Plan the task before execution. Do not modify files or execute commands.", 0, true);
629
+ const planning = planningResult.report;
543
630
  if (cancelled())
544
631
  return;
545
632
  this.planning = false;
546
633
  this.subagent = null;
547
634
  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. */ }
635
+ if (planningResult.status !== "completed" || !planning.trim()) {
636
+ throw new Error(`Pro planning failed: ${planningResult.error?.message ?? planning}`);
563
637
  }
638
+ this.plan = parsePlan(planning, 1);
639
+ if (!this.plan)
640
+ throw new Error("Pro planning failed: no usable plan steps were returned");
564
641
  await this.autoCompress(token);
565
642
  if (cancelled())
566
643
  return;
644
+ if (this.plan) {
645
+ preparePlan(this.plan);
646
+ if (currentTask())
647
+ currentTask().runtime.plan = this.plan;
648
+ }
567
649
  this.messages.push({ role: "assistant", content: this.plan ? planMessage(this.plan) : planning });
568
650
  this.save();
569
651
  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.";
652
+ const directive = `The planning agent produced this plan for the current user request:\n${JSON.stringify(this.plan ?? planning)}\n\n` +
653
+ "Execute the complete task using the appropriate professional agents. Own integration and verification. " +
654
+ "Call finish_step for each accepted step using its step_id and actual evidence IDs, existing artifact paths, or the final text deliverable. " +
655
+ "Record blocked or failed steps explicitly. Ending a reply never completes plan steps.";
572
656
  if (this.plan?.steps.length)
573
657
  this.setStep(0, "active");
574
658
  const finished = await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages), directive), token, approvalContext);
575
659
  if (cancelled())
576
660
  return;
577
661
  if (this.plan) {
578
- if (finished)
579
- for (let i = 0; i < this.plan.steps.length; i++)
580
- this.setStep(i, "done");
581
- else
662
+ settlePlan(this.plan, currentTask()?.signal.aborted === true);
663
+ if (!finished && this.taskOutcome !== "partial" && this.plan.steps[0]?.status === "blocked")
582
664
  this.setStep(0, "failed");
583
665
  }
584
666
  }
@@ -660,10 +742,11 @@ export class WebSession {
660
742
  return null;
661
743
  }
662
744
  finally {
663
- this.planning = false;
664
- this.retrying = null;
665
- if (token === this.turnToken)
745
+ if (token === this.turnToken) {
746
+ this.planning = false;
747
+ this.retrying = null;
666
748
  this.publishLive();
749
+ }
667
750
  }
668
751
  }
669
752
  /** Ends a turn whose floating promise rejected, instead of crashing the app. */
@@ -686,9 +769,10 @@ export class WebSession {
686
769
  buildApiMessages(messages = this.messages, directive = "") {
687
770
  // A step directive extends the system prompt: the plan is an instruction, not
688
771
  // something the user just said.
689
- const prompt = this.mode === "pro" ? loadProPrompt("main_model") : loadPrompt();
772
+ const prompt = this.mode === "pro" ? loadProPrompt("main_model") : loadAgentPrompt(this.activeAgentMode);
690
773
  const api = [
691
- { role: "system", content: directive ? `${prompt}\n\n${directive}` : prompt },
774
+ { role: "system", content: (directive ? `${prompt}\n\n${directive}` : prompt) +
775
+ (currentTask() ? `\n\n${currentTask().runtime.instructions()}` : "") },
692
776
  ];
693
777
  for (const m of modelHistory(messages, this.context)) {
694
778
  if (m.role === "user" || m.role === "assistant") {
@@ -706,8 +790,12 @@ export class WebSession {
706
790
  * the caller owns the turn's end because a planned turn runs this per step.
707
791
  */
708
792
  async agentLoop(apiMessages, token, approvalContext) {
709
- const tools = this.mode === "pro" ? getProToolDefinitions() : getToolDefinitions();
793
+ const tools = this.mode === "pro" ? [...getProToolDefinitions(), FINISH_STEP_DEFINITION] : [
794
+ ...getToolDefinitions(),
795
+ ...(this.activeAgentMode === "ptc" ? [(await import("../llm/codemode.js")).getCodeToolDefinition()] : []),
796
+ ];
710
797
  const team = this.proTeam;
798
+ let emptyReplies = 0;
711
799
  try {
712
800
  while (true) {
713
801
  if (token !== this.turnToken || this.paused)
@@ -715,11 +803,19 @@ export class WebSession {
715
803
  // Match the terminal: long tasks can keep using tools until the model
716
804
  // finishes, while older tool results are trimmed near the context limit.
717
805
  apiMessages = this.trimApiMessages(apiMessages);
806
+ if (currentTask())
807
+ await prepareContext({
808
+ messages: apiMessages, tools, capacity: team?.contextWindow("main_model") ?? modelContextWindow(this.provider, this.model, this.mode),
809
+ createClient: () => team?.client("main_model") ?? new LLMClient(this.model, this.provider, this.intensity),
810
+ stream: { signal: currentTask().runtime.signal, cancelled: () => token !== this.turnToken || this.paused },
811
+ onUsage: usage => { this.tokens.prompt += usage.prompt; this.tokens.completion += usage.completion; },
812
+ });
718
813
  const client = team ? team.client("main_model") : new LLMClient(this.model, this.provider, this.intensity);
719
814
  let text = "";
720
815
  this.streamingContent = "";
721
816
  this.publishLive();
722
817
  for await (const chunk of client.streamTurn(apiMessages, tools, {
818
+ maxOutputTokens: outputAllowance(team?.contextWindow("main_model") ?? modelContextWindow(this.provider, this.model, this.mode)),
723
819
  onRetry: (notice) => {
724
820
  this.retrying = notice;
725
821
  this.publishLive();
@@ -742,6 +838,30 @@ export class WebSession {
742
838
  this.tokens.completion += client.usage.completion;
743
839
  this.lastPromptTokens = client.usage.prompt;
744
840
  }
841
+ if (currentTask()?.signal.aborted || client.outcome?.status === "cancelled") {
842
+ this.taskOutcome = "cancelled";
843
+ this.streamingContent = null;
844
+ this.messages.push({ role: "assistant", content: `[Task stopped: ${String(currentTask()?.signal.reason ?? "Task cancelled")}]\n${currentTask()?.runtime.partialReport() ?? ""}` });
845
+ return false;
846
+ }
847
+ if (client.outcome?.error?.code.endsWith("_budget") && currentTask()) {
848
+ 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; });
849
+ if (token !== this.turnToken || this.paused)
850
+ return false;
851
+ this.taskOutcome = "partial";
852
+ this.streamingContent = null;
853
+ this.messages.push({ role: "assistant", content: report });
854
+ return false;
855
+ }
856
+ if (client.outcome?.status === "failed") {
857
+ const recorded = currentTask()?.runtime.evidence.length ? currentTask().runtime.partialReport() : "";
858
+ const message = client.outcome.error?.message ?? "Model request failed";
859
+ this.taskOutcome = recorded ? "partial" : "failed";
860
+ this.streamingContent = null;
861
+ this.messages.push({ role: "assistant", content: text +
862
+ (text.includes(message) ? "" : `\n[Error: ${message}]`) + (recorded ? `\n\n${recorded}` : "") });
863
+ return false;
864
+ }
745
865
  const toolCalls = client.toolCalls;
746
866
  if (text.trim()) {
747
867
  this.messages.push({ role: "assistant", content: text });
@@ -770,11 +890,17 @@ export class WebSession {
770
890
  this.save();
771
891
  this.publishLive();
772
892
  if (toolCalls.length === 0) {
773
- if (this.mode === "pro" && !text.trim()) {
893
+ if (!text.trim() && emptyReplies++ === 0)
894
+ continue;
895
+ if (!text.trim()) {
896
+ this.taskOutcome = "failed";
774
897
  this.messages.push({ role: "assistant", content: "[Error: Pro main model returned an empty completion.]" });
775
898
  return false;
776
899
  }
777
- return !/\[Error:/.test(text);
900
+ const success = client.outcome ? client.outcome.status === "completed" : !/\[Error:/.test(text);
901
+ if (!success)
902
+ this.taskOutcome = "failed";
903
+ return success;
778
904
  }
779
905
  for (const call of toolCalls) {
780
906
  if (token !== this.turnToken || this.paused)
@@ -804,6 +930,7 @@ export class WebSession {
804
930
  describeApprovalIntent(approvalContext) +
805
931
  "Do not retry this call unchanged; ask the user how to proceed.";
806
932
  toolMsg.result = denied;
933
+ currentTask()?.runtime.recordTool(call.name, args, denied, "denied");
807
934
  apiMessages.push({ role: "tool", content: denied, tool_call_id: call.id });
808
935
  this.save();
809
936
  this.publishLive();
@@ -814,6 +941,8 @@ export class WebSession {
814
941
  result = await this.runTool(call.name, args, toolMsg, token, apiMessages, approvalContext);
815
942
  }
816
943
  catch (err) {
944
+ if (token === this.turnToken)
945
+ this.taskOutcome = "failed";
817
946
  result = `Error: ${err instanceof Error ? err.message : String(err)}`;
818
947
  }
819
948
  if (this.disposed)
@@ -822,7 +951,8 @@ export class WebSession {
822
951
  this.save();
823
952
  if (token !== this.turnToken || this.paused)
824
953
  return false;
825
- apiMessages.push({ role: "tool", content: result, tool_call_id: call.id });
954
+ apiMessages.push({ role: "tool", content: toolMsg.evidenceId
955
+ ? `${result}\n[Task evidence: ${toolMsg.evidenceId}; status=${toolMsg.executionStatus}]` : result, tool_call_id: call.id });
826
956
  this.publishLive();
827
957
  }
828
958
  this.save();
@@ -856,15 +986,26 @@ export class WebSession {
856
986
  }
857
987
  // ---- approval gate ---------------------------------------------------
858
988
  /** Classify a tool call and, when it is risky, wait for a decision. */
859
- async requestApproval(tool, args, context = captureApprovalContext(this.messages)) {
989
+ async requestApproval(tool, args, context = captureApprovalContext(this.messages), signal = currentTask()?.runtime.signal ?? this.turnAbort?.signal) {
990
+ const linked = linkSignals(signal, currentTask()?.signal);
991
+ signal = linked.signal;
992
+ try {
993
+ currentTask()?.runtime.assertTool(tool, args);
994
+ }
995
+ catch (error) {
996
+ linked.dispose();
997
+ return { approved: false, source: "input", reason: error instanceof Error ? error.message : String(error) };
998
+ }
860
999
  const risk = assessToolCall(tool, args, this.dir);
861
- if (!risk)
1000
+ if (!risk) {
1001
+ linked.dispose();
862
1002
  return { approved: true, source: "prompt", reason: "routine call" };
1003
+ }
863
1004
  const approvalToken = this.turnToken;
864
1005
  const team = this.proTeam;
865
1006
  const gate = new ApprovalGate({
866
1007
  askHuman: () => new Promise((resolve) => {
867
- if (this.mode === "pro" && (approvalToken !== this.turnToken || this.paused || this.disposed)) {
1008
+ if (approvalToken !== this.turnToken || this.paused || this.disposed || signal?.aborted === true) {
868
1009
  resolve(false);
869
1010
  return;
870
1011
  }
@@ -875,12 +1016,12 @@ export class WebSession {
875
1016
  ...(this.mode === "pro" && team ? {
876
1017
  createReviewerClient: () => team.client("confirm_model"),
877
1018
  reviewerSystemPrompt: () => loadProPrompt("confirm_model"),
878
- cancelled: () => approvalToken !== this.turnToken || this.paused || this.disposed,
879
1019
  } : {}),
1020
+ cancelled: () => approvalToken !== this.turnToken || this.paused || this.disposed || signal?.aborted === true,
880
1021
  autoApprovalEnabled: () => this.approvalMode === "auto",
881
1022
  extremeAutoApprovalEnabled: () => loadConfig(this.mode).auto_approve_extreme_risk === true,
882
1023
  onReview: (info) => {
883
- if (this.mode === "pro" && (approvalToken !== this.turnToken || this.paused || this.disposed))
1024
+ if (approvalToken !== this.turnToken || this.paused || this.disposed || signal?.aborted === true)
884
1025
  return;
885
1026
  this.reviewing = info
886
1027
  ? {
@@ -891,7 +1032,22 @@ export class WebSession {
891
1032
  this.publishLive();
892
1033
  },
893
1034
  });
894
- return gate.decide({ tool, args, cwd: this.dir, ...context, risk });
1035
+ const abortApproval = () => {
1036
+ const pending = this.pendingApproval;
1037
+ if (pending?.args !== args)
1038
+ return;
1039
+ this.pendingApproval = null;
1040
+ pending.resolve(false);
1041
+ this.publishLive();
1042
+ };
1043
+ signal.addEventListener("abort", abortApproval, { once: true });
1044
+ try {
1045
+ return await gate.decide({ tool, args, cwd: this.dir, ...context, risk });
1046
+ }
1047
+ finally {
1048
+ signal.removeEventListener("abort", abortApproval);
1049
+ linked.dispose();
1050
+ }
895
1051
  }
896
1052
  /** Reviewer model: `approval_model` when set, otherwise the chat model. */
897
1053
  reviewerModel() {
@@ -905,63 +1061,172 @@ export class WebSession {
905
1061
  }
906
1062
  return { model: this.model, provider: this.provider, intensity: this.intensity };
907
1063
  }
1064
+ async runProgram(args, token, apiMessages, approvalContext) {
1065
+ if (this.activeAgentMode !== "ptc")
1066
+ return "[Error: execute_ts requires PTC mode]";
1067
+ const { executeCodeProgram, ProgramToolDenied } = await import("../llm/codemode.js");
1068
+ const signal = currentTask()?.runtime.signal ?? this.turnAbort?.signal;
1069
+ const cancelled = () => token !== this.turnToken || this.paused || signal?.aborted;
1070
+ const result = await executeCodeProgram(args, {
1071
+ signal,
1072
+ onPlan: plan => {
1073
+ if (cancelled())
1074
+ return;
1075
+ this.plan = plan;
1076
+ if (currentTask())
1077
+ currentTask().runtime.plan = plan;
1078
+ this.save();
1079
+ this.publishLive();
1080
+ },
1081
+ onStep: (index, status) => { if (!cancelled())
1082
+ this.setStep(index, status); },
1083
+ call: async (name, callArgs, programSignal) => {
1084
+ if (cancelled() || programSignal?.aborted)
1085
+ throw new Error("Execution cancelled");
1086
+ const message = { role: "tool", name, arguments: callArgs, result: "…" };
1087
+ this.messages.push(message);
1088
+ this.save();
1089
+ this.publishLive();
1090
+ const stopApproval = () => {
1091
+ if (this.pendingApproval?.args !== callArgs)
1092
+ return;
1093
+ const pending = this.pendingApproval;
1094
+ this.pendingApproval = null;
1095
+ pending.resolve(false);
1096
+ this.publishLive();
1097
+ };
1098
+ programSignal?.addEventListener("abort", stopApproval, { once: true });
1099
+ try {
1100
+ const decision = await this.requestApproval(name, callArgs, {
1101
+ ...approvalContext, context: captureApprovalContext(apiMessages).context,
1102
+ }, programSignal);
1103
+ if (cancelled() || programSignal?.aborted)
1104
+ throw new Error("Execution cancelled");
1105
+ if (!decision.approved) {
1106
+ message.result = `[Not executed: ${decision.reason}]`;
1107
+ throw new ProgramToolDenied(`Approval denied: ${decision.reason}. Do not retry or work around this denial.`);
1108
+ }
1109
+ const value = await this.runTool(name, callArgs, message, token, apiMessages, approvalContext, programSignal);
1110
+ message.result = value;
1111
+ if (cancelled() || programSignal?.aborted)
1112
+ throw new Error("Execution cancelled");
1113
+ return value;
1114
+ }
1115
+ catch (error) {
1116
+ if (message.result === "…")
1117
+ message.result = `[Error: ${error instanceof Error ? error.message : String(error)}]`;
1118
+ throw error;
1119
+ }
1120
+ finally {
1121
+ programSignal?.removeEventListener("abort", stopApproval);
1122
+ if (this.messages.includes(message)) {
1123
+ this.save();
1124
+ this.publishLive();
1125
+ }
1126
+ }
1127
+ },
1128
+ });
1129
+ if (!cancelled()) {
1130
+ this.save();
1131
+ this.publishLive();
1132
+ }
1133
+ return result;
1134
+ }
908
1135
  // ---- sub-agents ------------------------------------------------------
909
1136
  /**
910
1137
  * Run one approved tool call. `delegate` runs a child agent instead of
911
1138
  * touching the filesystem, so it is handled here, where the session's model,
912
1139
  * approval gate and abort signal live.
913
1140
  */
914
- async runTool(name, args, toolMsg, token, apiMessages, approvalContext) {
1141
+ async runTool(name, args, toolMsg, token, apiMessages, approvalContext, executionSignal) {
1142
+ const runtime = currentTask()?.runtime;
1143
+ const execute = () => this.dispatchTool(name, args, toolMsg, token, apiMessages, approvalContext, executionSignal);
1144
+ if (!runtime)
1145
+ return execute();
1146
+ const result = await runtime.tool(name, args, execute);
1147
+ const evidence = runtime.evidence.at(-1);
1148
+ if (evidence && this.mode === "pro") {
1149
+ toolMsg.evidenceId = evidence.id;
1150
+ toolMsg.executionStatus = evidence.status;
1151
+ }
1152
+ return result;
1153
+ }
1154
+ async dispatchTool(name, args, toolMsg, token, apiMessages, approvalContext, signal = currentTask()?.runtime.signal ?? this.turnAbort?.signal) {
1155
+ if (name === FINISH_STEP_TOOL && currentTask()) {
1156
+ const result = finishPlanStep(this.plan, args, currentTask().runtime);
1157
+ this.save();
1158
+ this.publishLive();
1159
+ return result;
1160
+ }
915
1161
  if (this.mode === "pro" && (name === "call_pro_agent" || name === DELEGATE_TOOL)) {
916
1162
  const team = this.proTeam;
917
1163
  if (!team)
918
1164
  return "[Error: Pro models have not passed preflight]";
919
1165
  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);
1166
+ captureApprovalContext(apiMessages).context, 1, args.read_only === true, Array.isArray(args.write_paths) ? args.write_paths.filter((p) => typeof p === "string") : undefined);
921
1167
  }
1168
+ if (name === "execute_ts")
1169
+ return this.runProgram(args, token, apiMessages, approvalContext);
922
1170
  if (name !== DELEGATE_TOOL)
923
- return executeTool(name, args, this.dir);
924
- return this.delegate(args, toolMsg, token, apiMessages, approvalContext);
1171
+ return executeTool(name, args, this.dir, signal);
1172
+ return this.delegate(args, toolMsg, token, apiMessages, approvalContext, signal);
925
1173
  }
926
1174
  /**
927
1175
  * Run a sub-agent for one `delegate` call and return its report. The child
928
1176
  * works in its own transcript; only the report reaches this conversation, and
929
1177
  * its risky calls go through the same approval gate as the parent turn.
930
1178
  */
931
- async delegate(args, toolMsg, token, apiMessages, approvalContext) {
1179
+ async delegate(args, toolMsg, token, apiMessages, approvalContext, executionSignal) {
932
1180
  const task = String(args.task ?? "").trim();
933
1181
  if (!task)
934
1182
  return "[Error: delegate needs a task: what should the sub-agent do?]";
935
1183
  const context = typeof args.context === "string" ? args.context.trim() : "";
936
1184
  const approval = { ...approvalContext, context: captureApprovalContext(apiMessages).context };
1185
+ const maxRounds = subAgentRounds(loadConfig(this.mode).subagent_max_rounds);
1186
+ const runtime = currentTask()?.runtime;
1187
+ const signal = executionSignal ?? runtime?.signal ?? this.turnAbort?.signal;
1188
+ const stopped = () => token !== this.turnToken || this.paused || signal?.aborted === true;
937
1189
  const run = await runSubAgentTask({
1190
+ runtime, signal,
1191
+ readOnly: args.read_only === true,
1192
+ writePaths: Array.isArray(args.write_paths) ? args.write_paths.filter((p) => typeof p === "string") : undefined,
1193
+ contextWindow: modelContextWindow(this.provider, this.model, this.mode),
938
1194
  createClient: () => new LLMClient(this.model, this.provider, this.intensity),
939
1195
  system: loadSubAgentPrompt(),
940
- task: buildSubAgentTask({ task, context, cwd: this.dir }),
1196
+ task: buildSubAgentTask({ task, context, cwd: this.dir, rounds: maxRounds }),
941
1197
  tools: SUB_AGENT_TOOLS,
942
- approve: (name, callArgs) => this.requestApproval(name, callArgs, approval),
1198
+ maxRounds,
1199
+ approve: (name, callArgs) => this.requestApproval(name, callArgs, approval, signal),
943
1200
  execute: (name, callArgs) => name === DELEGATE_TOOL
944
1201
  ? Promise.resolve("[Error: sub-agents cannot delegate further]")
945
- : executeTool(name, callArgs, this.dir),
1202
+ : executeTool(name, callArgs, this.dir, signal),
946
1203
  onProgress: (label) => {
1204
+ if (stopped())
1205
+ return;
947
1206
  this.subagent = label;
948
1207
  toolMsg.result = `… ${label}`;
949
1208
  this.publishLive();
950
1209
  },
951
1210
  onUsage: (usage) => {
1211
+ if (stopped())
1212
+ return;
952
1213
  this.tokens.prompt += usage.prompt;
953
1214
  this.tokens.completion += usage.completion;
954
1215
  },
955
1216
  onRetry: (notice) => {
1217
+ if (stopped())
1218
+ return;
956
1219
  this.retrying = notice;
957
1220
  this.publishLive();
958
1221
  },
959
- cancelled: () => token !== this.turnToken || this.paused || this.disposed,
1222
+ cancelled: stopped,
960
1223
  });
961
- this.subagent = null;
962
- this.retrying = null;
963
- toolMsg.result = TOOL_PENDING;
964
- this.publishLive();
1224
+ if (!stopped()) {
1225
+ this.subagent = null;
1226
+ this.retrying = null;
1227
+ toolMsg.result = TOOL_PENDING;
1228
+ this.publishLive();
1229
+ }
965
1230
  return formatSubAgentResult(run);
966
1231
  }
967
1232
  // ---- compression -----------------------------------------------------
@@ -1067,7 +1332,7 @@ export class WebSession {
1067
1332
  if (chat.length === 0)
1068
1333
  return true;
1069
1334
  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);
1335
+ 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
1336
  this.saveError = null;
1072
1337
  return true;
1073
1338
  }