@themoltnet/pi-extension 0.32.0 → 0.33.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.
Files changed (2) hide show
  1. package/dist/index.js +366 -156
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1965,7 +1965,7 @@ var findLatestRuntimeSlotForAttempt = (options) => (options.client ?? client).ge
1965
1965
  ...options
1966
1966
  });
1967
1967
  /**
1968
- * Delete terminal tasks in bulk. Safe mode skips live, unauthorized, missing, and protected tasks.
1968
+ * Queue asynchronous deletion of terminal tasks in bulk. By default, live, unauthorized, missing, and protected tasks are skipped. Set force: true with a reason to delete protected terminal tasks.
1969
1969
  */
1970
1970
  var batchDeleteTasks = (options) => (options.client ?? client).delete({
1971
1971
  security: [
@@ -18037,7 +18037,7 @@ function createMoltNetTools(config) {
18037
18037
  const searchEntries = defineTool({
18038
18038
  name: "moltnet_search_entries",
18039
18039
  label: "Search MoltNet Diary Entries",
18040
- description: "Hybrid (semantic + lexical) search over diary entries. Optional tags / excludeTags / entryTypes filters AND with the query; the taskFilter shorthand expands into task:* provenance tags so `taskFilter: { taskType: \"fulfill_brief\" }` returns only entries from fulfill_brief attempts. Filters apply server-side before ranking.",
18040
+ description: "Hybrid (semantic + lexical) search over diary entries. Use proactively before non-trivial investigation, code changes, review, or episodic incident capture so prior decisions and recurring failures surface before you act. Do not search randomly: pass taskFilter for task/correlation-local searches and tags or entryTypes for broader prior-knowledge searches. Optional tags / excludeTags / entryTypes filters AND with the query; the taskFilter shorthand expands into task:* provenance tags so `taskFilter: { taskType: \"fulfill_brief\" }` returns only entries from fulfill_brief attempts. Filters apply server-side before ranking.",
18041
18041
  parameters: Type.Object({
18042
18042
  query: Type.String({ description: "Natural language search query" }),
18043
18043
  limit: Type.Optional(Type.Number({ description: "Max results (default 5)" })),
@@ -18096,12 +18096,18 @@ function createMoltNetTools(config) {
18096
18096
  const createEntry = defineTool({
18097
18097
  name: "moltnet_create_entry",
18098
18098
  label: "Create MoltNet Diary Entry",
18099
- description: "Create a new diary entry to record decisions, findings, incidents, or reflections. During an active task, the entry is forced into the task diary and tagged with the task:* provenance namespace (task:id:<id>, task:type:<type>, task:attempt:<n>, plus task:correlation:<id> when set); an explicit diaryId mismatching the task diary is rejected. Use this tool — NOT `moltnet entry create` / `moltnet entry create-signed` via bash. The CLI path bypasses task-tag auto-injection and leaves entries invisible to taskFilter queries.",
18099
+ description: "Create a new diary entry to record decisions, findings, incidents, or reflections. Before creating an episodic incident entry, first call moltnet_search_entries with the title/root-cause/error/watch-for terms plus taskFilter, tags, or entryTypes filters, then reference close matches instead of creating an isolated duplicate. During an active task, the entry is forced into the task diary and tagged with the task:* provenance namespace (task:id:<id>, task:type:<type>, task:attempt:<n>, plus task:correlation:<id> when set); an explicit diaryId mismatching the task diary is rejected. Use this tool — NOT `moltnet entry create` / `moltnet entry create-signed` via bash. The CLI path bypasses task-tag auto-injection and leaves entries invisible to taskFilter queries.",
18100
18100
  parameters: Type.Object({
18101
18101
  title: Type.String({ description: "Entry title (concise, descriptive)" }),
18102
18102
  content: Type.String({ description: "Entry content (markdown)" }),
18103
18103
  tags: Type.Optional(Type.Array(Type.String(), { description: "Tags for categorization" })),
18104
18104
  importance: Type.Optional(Type.Number({ description: "Importance 1-10 (default 5)" })),
18105
+ entryType: Type.Optional(Type.Union([
18106
+ Type.Literal("episodic"),
18107
+ Type.Literal("semantic"),
18108
+ Type.Literal("procedural"),
18109
+ Type.Literal("reflection")
18110
+ ], { description: "Entry type. Use episodic for incidents, workarounds, bugs, or recurrence evidence; defaults to semantic." })),
18105
18111
  diaryId: Type.Optional(Type.String({ description: "Explicit diary id. During an active task, must match the task diary or the call is rejected. Outside a task, overrides the env-derived diary." }))
18106
18112
  }),
18107
18113
  async execute(_id, params) {
@@ -18125,7 +18131,8 @@ function createMoltNetTools(config) {
18125
18131
  title: params.title,
18126
18132
  content: params.content,
18127
18133
  tags: mergedTags,
18128
- importance: params.importance ?? 5
18134
+ importance: params.importance ?? 5,
18135
+ ...params.entryType ? { entryType: params.entryType } : {}
18129
18136
  });
18130
18137
  return {
18131
18138
  content: [{
@@ -18135,6 +18142,8 @@ function createMoltNetTools(config) {
18135
18142
  title: entry.title,
18136
18143
  createdAt: entry.createdAt,
18137
18144
  diaryId: targetDiaryId,
18145
+ entryType: entry.entryType,
18146
+ importance: entry.importance,
18138
18147
  tags: mergedTags
18139
18148
  }, null, 2)
18140
18149
  }],
@@ -18514,6 +18523,29 @@ function buildRuntimeInstructor(ctx) {
18514
18523
  " requires human approval and is unavailable in headless task runs;",
18515
18524
  " never use it for routine git/gh.",
18516
18525
  "",
18526
+ "## Proactive memory use",
18527
+ "",
18528
+ "- Before non-trivial investigation, debugging, code changes, or review,",
18529
+ " check the task diary for relevant prior knowledge instead of waiting",
18530
+ " for a human to ask. Use `moltnet_diary_tags` for cheap reconnaissance,",
18531
+ " `moltnet_list_entries` when tags or task provenance are known, and",
18532
+ " `moltnet_search_entries` for semantic similarity. Do not search",
18533
+ " randomly: pass `taskFilter` for task-local or correlation-local",
18534
+ " queries, and pass `tags` / `entryTypes` for broader prior-knowledge",
18535
+ " queries using known tags such as `incident`, `decision`, or",
18536
+ " `scope:<area>`. Broaden only after constrained searches miss.",
18537
+ "- Before creating an `episodic` incident entry, you MUST search for",
18538
+ " similar incidents using the proposed title, root cause, error text,",
18539
+ " affected subsystem, and watch-for terms, filtered by `entryTypes:",
18540
+ " [\"episodic\", \"semantic\"]` and any known `scope:*` / task provenance",
18541
+ " tags. If a close prior match exists, do not create an isolated",
18542
+ " duplicate: reference the prior entry in your response or diary content,",
18543
+ " update/link it when the new occurrence adds material evidence, or",
18544
+ " create a new recurrence entry only when the recurrence itself is",
18545
+ " important signal.",
18546
+ "- When you create a recurrence entry, include the prior matching entry",
18547
+ " id(s) in the content and explain what is new about this occurrence.",
18548
+ "",
18517
18549
  "## Diary discipline",
18518
18550
  "",
18519
18551
  `- During this task, every diary entry MUST land in \`${ctx.diaryId}\``,
@@ -20114,6 +20146,22 @@ function buildFinalOutputBlock(opts) {
20114
20146
  return lines.join("\n");
20115
20147
  }
20116
20148
  //#endregion
20149
+ //#region ../agent-runtime/src/prompts/proactive-memory.ts
20150
+ function buildProactiveMemoryWorkflowBlock() {
20151
+ return [
20152
+ "Before material work, apply the runtime instructor's proactive memory",
20153
+ "rules instead of waiting for a human prompt. Start with constrained",
20154
+ "diary context: inspect tags/list entries when task provenance or scope",
20155
+ "tags are known, then use `moltnet_search_entries` with `taskFilter`,",
20156
+ "`entryTypes`, and tags. Do not run broad unfiltered searches before",
20157
+ "constrained searches miss.",
20158
+ "",
20159
+ "For incident capture, follow the runtime instructor exactly: search",
20160
+ "for similar episodic/semantic entries first, reference close matches,",
20161
+ "and create a recurrence entry only when the repeat is useful signal."
20162
+ ].join("\n");
20163
+ }
20164
+ //#endregion
20117
20165
  //#region ../agent-runtime/src/prompts/rubric-common.ts
20118
20166
  function renderRubricCriteriaList(rubric) {
20119
20167
  return rubric.criteria.map((c, i) => `${i + 1}. **${c.id}** (weight ${c.weight}, scoring: \`${c.scoring}\`) — ${c.description}`).join("\n");
@@ -20215,6 +20263,12 @@ function buildAssessBriefUserPrompt(input, ctx) {
20215
20263
  header: "Querying the producer's diary entries",
20216
20264
  body: diaryQuery
20217
20265
  },
20266
+ {
20267
+ id: "assess_brief.proactive_memory",
20268
+ source: "discipline",
20269
+ header: "Proactive memory use",
20270
+ body: buildProactiveMemoryWorkflowBlock()
20271
+ },
20218
20272
  {
20219
20273
  id: "assess_brief.workspace",
20220
20274
  source: "workspace",
@@ -20542,11 +20596,13 @@ function buildFreeformUserPrompt(input, ctx) {
20542
20596
  const suggestedTaskType = input.suggestedTaskType ? [`The proposer suggested task type \`${input.suggestedTaskType}\`.`, "Use it as a hint, not as a contract."].join("\n") : "";
20543
20597
  const workflow = [
20544
20598
  "1. Clarify the real objective from the brief before acting.",
20545
- "2. Gather enough context to avoid guessing.",
20546
- "3. Complete the requested work when it is safe and bounded.",
20547
- "4. If the request reveals a recurring task shape, include a",
20599
+ "2. Search MoltNet diary memory for prior decisions, incidents, and",
20600
+ " recurring traps relevant to the brief.",
20601
+ "3. Gather enough context to avoid guessing.",
20602
+ "4. Complete the requested work when it is safe and bounded.",
20603
+ "5. If the request reveals a recurring task shape, include a",
20548
20604
  " `proposedTaskType` in the final output with a concise rationale.",
20549
- "5. If you changed code on a branch, include that branch in",
20605
+ "6. If you changed code on a branch, include that branch in",
20550
20606
  " `branch` so future continuations can recover git context."
20551
20607
  ].join("\n");
20552
20608
  const sections = [
@@ -20585,6 +20641,12 @@ function buildFreeformUserPrompt(input, ctx) {
20585
20641
  header: "Workflow",
20586
20642
  body: workflow
20587
20643
  },
20644
+ {
20645
+ id: "freeform.proactive_memory",
20646
+ source: "discipline",
20647
+ header: "Proactive memory use",
20648
+ body: buildProactiveMemoryWorkflowBlock()
20649
+ },
20588
20650
  {
20589
20651
  id: "freeform.verification",
20590
20652
  source: "verification",
@@ -20661,13 +20723,15 @@ function buildFulfillBriefUserPrompt(input, ctx) {
20661
20723
  ].join("\n") : "";
20662
20724
  const workflow = [
20663
20725
  ctx.workspace?.mode === "dedicated_worktree" ? `1. Use the already-provisioned dedicated worktree branch${ctx.workspace.branch ? ` (\`${ctx.workspace.branch}\`)` : ""}; do not create or switch the primary checkout.` : `1. Create a feature branch (starting prefix suggestion: \`${branchSlug}<short-slug>\`).`,
20664
- "2. Understand the problem read relevant code; do not speculate.",
20665
- "3. Implement the change. Keep commits small and coherent.",
20666
- "4. Add tests if applicable.",
20667
- "5. For every commit, create a signed diary entry first via",
20726
+ "2. Search MoltNet diary memory for prior decisions, incidents, and",
20727
+ " recurring traps relevant to the brief before changing code.",
20728
+ "3. Understand the problem — read relevant code; do not speculate.",
20729
+ "4. Implement the change. Keep commits small and coherent.",
20730
+ "5. Add tests if applicable.",
20731
+ "6. For every commit, create a signed diary entry first via",
20668
20732
  " `moltnet_create_entry` and embed its id in the commit trailer",
20669
20733
  " `MoltNet-Diary: <id>` (per the runtime instructor).",
20670
- "6. Push the branch and open a PR — run `git push` and `gh pr create`",
20734
+ "7. Push the branch and open a PR — run `git push` and `gh pr create`",
20671
20735
  " IN the VM with your normal `bash` tool (use the",
20672
20736
  " `GH_TOKEN=$(moltnet github token …) gh …` form from the runtime",
20673
20737
  " instructor). Do NOT use `moltnet_host_exec` for this; it needs human",
@@ -20709,6 +20773,12 @@ function buildFulfillBriefUserPrompt(input, ctx) {
20709
20773
  header: "Workflow",
20710
20774
  body: workflow
20711
20775
  },
20776
+ {
20777
+ id: "fulfill_brief.proactive_memory",
20778
+ source: "discipline",
20779
+ header: "Proactive memory use",
20780
+ body: buildProactiveMemoryWorkflowBlock()
20781
+ },
20712
20782
  {
20713
20783
  id: "fulfill_brief.verification",
20714
20784
  source: "verification",
@@ -21024,13 +21094,15 @@ function buildPrReviewUserPrompt(input, ctx) {
21024
21094
  const workflow = [
21025
21095
  "1. Read the subject summary, resources, inspection hints, and any",
21026
21096
  " task-specific instructions before scoring.",
21027
- "2. Inspect the target artefact directly using the tools and resources the",
21097
+ "2. Search MoltNet diary memory for prior decisions, incidents, and",
21098
+ " recurring review traps relevant to the subject.",
21099
+ "3. Inspect the target artefact directly using the tools and resources the",
21028
21100
  " task makes available.",
21029
- "3. If you are in a dedicated disposable worktree and need the review target",
21101
+ "4. If you are in a dedicated disposable worktree and need the review target",
21030
21102
  " checked out locally, do that work inside this disposable workspace only.",
21031
- "4. Apply the rubric strictly. This task is about complexity and",
21103
+ "5. Apply the rubric strictly. This task is about complexity and",
21032
21104
  " reviewability, not correctness or feature desirability.",
21033
- "5. Perform any required outward action before emitting the final",
21105
+ "6. Perform any required outward action before emitting the final",
21034
21106
  " structured output."
21035
21107
  ].join("\n");
21036
21108
  const taskPromptSection = input.taskPrompt ?? "";
@@ -21087,6 +21159,12 @@ function buildPrReviewUserPrompt(input, ctx) {
21087
21159
  header: "Review workflow",
21088
21160
  body: workflow
21089
21161
  },
21162
+ {
21163
+ id: "pr_review.proactive_memory",
21164
+ source: "discipline",
21165
+ header: "Proactive memory use",
21166
+ body: buildProactiveMemoryWorkflowBlock()
21167
+ },
21090
21168
  {
21091
21169
  id: "pr_review.task_prompt",
21092
21170
  source: "task_input",
@@ -26409,14 +26487,10 @@ async function executePiTask(claimedTask, reporter, opts) {
26409
26487
  });
26410
26488
  return makeFailedOutput("session_setup_failed", message);
26411
26489
  }
26412
- let llmAbort = false;
26413
- let llmErrorMessage = null;
26414
- let assistantText = "";
26490
+ const turnState = createSessionTurnState();
26415
26491
  let reporterError = null;
26416
26492
  const usage = finalUsage;
26417
26493
  let capAbort = null;
26418
- let toolUseTurnCount = 0;
26419
- let bashTimeoutCount = 0;
26420
26494
  const maxTurns = opts.maxTurns ?? 0;
26421
26495
  const maxBashTimeouts = opts.maxBashTimeouts ?? 3;
26422
26496
  cancelListener = wireSessionAbort(reporter.cancelSignal, session);
@@ -26451,49 +26525,16 @@ async function executePiTask(claimedTask, reporter, opts) {
26451
26525
  message
26452
26526
  }));
26453
26527
  };
26454
- session.subscribe((event) => {
26455
- if (event.type === "message_update") {
26456
- const ae = event.assistantMessageEvent;
26457
- if (ae.type === "text_delta") {
26458
- assistantText += ae.delta;
26459
- track(emit("text_delta", { delta: ae.delta }));
26460
- }
26461
- } else if (event.type === "tool_execution_start") track(emit("tool_call_start", { tool_name: event.toolName }));
26462
- else if (event.type === "tool_execution_end") {
26463
- track(emit("tool_call_end", {
26464
- tool_name: event.toolName,
26465
- is_error: event.isError,
26466
- result: event.isError ? truncateForWire(event.result) : void 0
26467
- }));
26468
- if (shouldEmitToolCallError(event)) track(emitError("tool_call_error", describeToolErrorMessage(event.result), {
26469
- tool: event.toolName,
26470
- result: truncateForWire(event.result)
26471
- }));
26472
- if (maxBashTimeouts > 0 && event.toolName === "bash" && event.isError && isBashTimeoutResult(event.result)) {
26473
- bashTimeoutCount += 1;
26474
- if (bashTimeoutCount >= maxBashTimeouts) triggerCapAbort("max_bash_timeouts_exceeded", `Aborted after ${bashTimeoutCount} bash timeouts in this attempt (cap ${maxBashTimeouts}).`);
26475
- }
26476
- } else if (event.type === "turn_end") {
26477
- const msg = event.message;
26478
- if (msg?.role === "assistant" && msg.usage) {
26479
- usage.inputTokens += Math.max(0, msg.usage.input ?? 0);
26480
- usage.outputTokens += Math.max(0, msg.usage.output ?? 0);
26481
- const cr = Math.max(0, msg.usage.cacheRead ?? 0);
26482
- const cw = Math.max(0, msg.usage.cacheWrite ?? 0);
26483
- if (cr) usage.cacheReadTokens = (usage.cacheReadTokens ?? 0) + cr;
26484
- if (cw) usage.cacheWriteTokens = (usage.cacheWriteTokens ?? 0) + cw;
26485
- }
26486
- const stopReason = msg?.stopReason ?? "end_turn";
26487
- track(emit("turn_end", { stop_reason: stopReason }));
26488
- if (maxTurns > 0 && stopReason !== "end_turn" && stopReason !== "aborted" && stopReason !== "error") {
26489
- toolUseTurnCount += 1;
26490
- if (toolUseTurnCount >= maxTurns) triggerCapAbort("max_turns_exceeded", `Aborted after ${toolUseTurnCount} tool-use turns (cap ${maxTurns}).`);
26491
- }
26492
- llmAbort = msg?.stopReason === "error";
26493
- if (msg?.stopReason === "error") llmErrorMessage = typeof msg.errorMessage === "string" && msg.errorMessage.length > 0 ? msg.errorMessage : null;
26494
- else llmErrorMessage = null;
26495
- }
26496
- });
26528
+ session.subscribe(makeSessionEventHandler({
26529
+ state: turnState,
26530
+ usage,
26531
+ maxTurns,
26532
+ maxBashTimeouts,
26533
+ emit,
26534
+ emitError,
26535
+ track,
26536
+ triggerCapAbort
26537
+ }));
26497
26538
  let runError = null;
26498
26539
  const runPrompt = (promptText) => promptWithProviderErrorRetries({
26499
26540
  session: liveSession,
@@ -26501,8 +26542,8 @@ async function executePiTask(claimedTask, reporter, opts) {
26501
26542
  cancelSignal: reporter.cancelSignal,
26502
26543
  isCapAborted: () => capAbort !== null,
26503
26544
  getProviderErrorState: () => ({
26504
- llmAbort,
26505
- llmErrorMessage
26545
+ llmAbort: turnState.llmAbort,
26546
+ llmErrorMessage: turnState.llmErrorMessage
26506
26547
  }),
26507
26548
  maxRetries: opts.maxProviderErrorRetries ?? 2,
26508
26549
  baseDelayMs: opts.providerErrorRetryBaseDelayMs ?? 2e3,
@@ -26531,7 +26572,7 @@ async function executePiTask(claimedTask, reporter, opts) {
26531
26572
  isStopped: () => submitRepromptStopped({
26532
26573
  cancelled: reporter.cancelSignal.aborted,
26533
26574
  capAborted: capAbort !== null,
26534
- llmAbort
26575
+ llmAbort: turnState.llmAbort
26535
26576
  }),
26536
26577
  onSubmitReprompt: async (event) => {
26537
26578
  await emit("info", event);
@@ -26552,62 +26593,18 @@ async function executePiTask(claimedTask, reporter, opts) {
26552
26593
  let parsedOutput = null;
26553
26594
  let parsedOutputCid = null;
26554
26595
  let parseError = null;
26555
- if (!runError && !llmAbort && !cancelled && !capAbort) {
26556
- const captured = submitToolHandle?.getCaptured() ?? null;
26557
- if (captured) try {
26558
- parsedOutput = captured;
26559
- parsedOutputCid = await computeJsonCid(captured);
26560
- recordTaskOutputParseResult({
26561
- taskType: task.taskType,
26562
- model: opts.model,
26563
- code: "captured_via_tool"
26564
- });
26565
- } catch (err) {
26566
- const message = err instanceof Error ? err.message : String(err);
26567
- parsedOutput = null;
26568
- parsedOutputCid = null;
26569
- parseError = {
26570
- code: "output_cid_compute_failed",
26571
- message: `Captured submit-tool output could not be canonicalized: ${message}`
26572
- };
26573
- recordTaskOutputParseResult({
26574
- taskType: task.taskType,
26575
- model: opts.model,
26576
- code: "output_cid_compute_failed"
26577
- });
26578
- await emit("error", {
26579
- message: parseError.message,
26580
- phase: "output_validation"
26581
- });
26582
- }
26583
- else if (submitToolHandle) {
26584
- const exhausted = submitToolHandle.getExhaustedValidationFailure();
26585
- parseError = exhausted ?? {
26586
- code: "submit_output_missing",
26587
- message: "Agent did not satisfy the promised submit-output criterion: no valid task submit tool call was captured before the session ended."
26588
- };
26589
- if (!exhausted) recordTaskOutputParseResult({
26590
- taskType: task.taskType,
26591
- model: opts.model,
26592
- code: "output_missing"
26593
- });
26594
- await emit("error", {
26595
- message: parseError.message,
26596
- phase: "output_validation"
26597
- });
26598
- } else {
26599
- const parsed = await parseStructuredTaskOutput(assistantText, task.taskType, {
26600
- model: opts.model,
26601
- input: task.input
26602
- });
26603
- parsedOutput = parsed.output;
26604
- parsedOutputCid = parsed.outputCid;
26605
- parseError = parsed.error;
26606
- if (parseError) await emit("error", {
26607
- message: parseError.message,
26608
- phase: "output_validation"
26609
- });
26610
- }
26596
+ if (!runError && !turnState.llmAbort && !cancelled && !capAbort) {
26597
+ const captured = await captureAttemptOutput({
26598
+ taskType: task.taskType,
26599
+ model: opts.model,
26600
+ input: task.input,
26601
+ assistantText: turnState.assistantText,
26602
+ submitToolHandle,
26603
+ emit
26604
+ });
26605
+ parsedOutput = captured.output;
26606
+ parsedOutputCid = captured.outputCid;
26607
+ parseError = captured.error;
26611
26608
  }
26612
26609
  if (cancelled) return {
26613
26610
  taskId: task.id,
@@ -26638,54 +26635,267 @@ async function executePiTask(claimedTask, reporter, opts) {
26638
26635
  retryable: false
26639
26636
  }
26640
26637
  };
26641
- const reporterErrorSnapshot = reporterError;
26642
- const status = runError || llmAbort || parseError || reporterErrorSnapshot ? "failed" : "completed";
26643
- const errorCode = runError?.code ?? parseError?.code ?? reporterErrorSnapshot?.code ?? (llmAbort ? "llm_api_error" : void 0);
26644
- const errorMessage = runError?.message ?? parseError?.message ?? reporterErrorSnapshot?.message ?? (llmAbort ? llmErrorMessage ?? "LLM API error during turn" : void 0);
26645
- const errorRetryable = reporterErrorSnapshot && errorCode === reporterErrorSnapshot.code && errorMessage === reporterErrorSnapshot.message ? reporterErrorSnapshot.retryable ?? false : false;
26646
- return {
26638
+ return buildAttemptResult({
26647
26639
  taskId: task.id,
26648
26640
  attemptN,
26649
- status,
26650
26641
  output: parsedOutput,
26651
26642
  outputCid: parsedOutputCid,
26652
26643
  usage,
26653
26644
  durationMs: Date.now() - startTime,
26654
- ...errorCode && errorMessage ? { error: {
26655
- code: errorCode,
26656
- message: errorMessage,
26657
- retryable: errorRetryable
26658
- } } : {}
26659
- };
26645
+ runError,
26646
+ parseError,
26647
+ reporterError,
26648
+ llmAbort: turnState.llmAbort,
26649
+ llmErrorMessage: turnState.llmErrorMessage
26650
+ });
26660
26651
  } catch (err) {
26661
26652
  return makeFailedOutput("executor_unexpected_error", err instanceof Error ? err.message : String(err));
26662
26653
  } finally {
26663
- if (cancelListener) reporter.cancelSignal.removeEventListener("abort", cancelListener);
26664
- if (session) try {
26665
- session.dispose();
26666
- } catch {}
26667
- if (reporterOpen) {
26668
- try {
26669
- await reporter.finalize(finalUsage);
26670
- } catch (err) {
26671
- const detail = err instanceof Error ? err.message : String(err);
26672
- console.error(`executePiTask: reporter.finalize() failed for task ${task.id} attempt ${attemptN}: ${detail}`);
26654
+ await cleanupAttempt({
26655
+ cancelSignal: reporter.cancelSignal,
26656
+ cancelListener,
26657
+ session,
26658
+ reporterOpen,
26659
+ reporter,
26660
+ finalUsage,
26661
+ managed,
26662
+ workspace,
26663
+ taskId: task.id,
26664
+ attemptN
26665
+ });
26666
+ }
26667
+ }
26668
+ function createSessionTurnState() {
26669
+ return {
26670
+ assistantText: "",
26671
+ llmAbort: false,
26672
+ llmErrorMessage: null,
26673
+ toolUseTurnCount: 0,
26674
+ bashTimeoutCount: 0
26675
+ };
26676
+ }
26677
+ /**
26678
+ * Build the `AgentSession.subscribe` handler for one attempt: bridges pi
26679
+ * events to the reporter, accumulates token usage and assistant text, and
26680
+ * enforces the bash-timeout and tool-use-turn caps. Extracted from
26681
+ * `executePiTask` so this dense, branch-heavy logic is unit-tested against a
26682
+ * scripted event stream instead of only through a booted VM.
26683
+ *
26684
+ * The handler mutates `deps.state` and `deps.usage` in place; the caller reads
26685
+ * them after `session.prompt()` resolves (by which point `state.llmAbort`
26686
+ * holds the terminal turn's outcome — see the "last-turn wins" note below).
26687
+ *
26688
+ * @internal Exported for unit testing; not part of the package's public API.
26689
+ */
26690
+ function makeSessionEventHandler(deps) {
26691
+ const { state, usage, maxTurns, maxBashTimeouts, emit, emitError, track, triggerCapAbort } = deps;
26692
+ return (event) => {
26693
+ if (event.type === "message_update") {
26694
+ const ae = event.assistantMessageEvent;
26695
+ if (ae.type === "text_delta") {
26696
+ state.assistantText += ae.delta;
26697
+ track(emit("text_delta", { delta: ae.delta }));
26673
26698
  }
26674
- try {
26675
- await reporter.close();
26676
- } catch (err) {
26677
- const detail = err instanceof Error ? err.message : String(err);
26678
- console.error(`executePiTask: reporter.close() failed for task ${task.id} attempt ${attemptN}: ${detail}`);
26699
+ } else if (event.type === "tool_execution_start") track(emit("tool_call_start", { tool_name: event.toolName }));
26700
+ else if (event.type === "tool_execution_end") {
26701
+ track(emit("tool_call_end", {
26702
+ tool_name: event.toolName,
26703
+ is_error: event.isError,
26704
+ result: event.isError ? truncateForWire(event.result) : void 0
26705
+ }));
26706
+ if (shouldEmitToolCallError(event)) track(emitError("tool_call_error", describeToolErrorMessage(event.result), {
26707
+ tool: event.toolName,
26708
+ result: truncateForWire(event.result)
26709
+ }));
26710
+ if (maxBashTimeouts > 0 && event.toolName === "bash" && event.isError && isBashTimeoutResult(event.result)) {
26711
+ state.bashTimeoutCount += 1;
26712
+ if (state.bashTimeoutCount >= maxBashTimeouts) triggerCapAbort("max_bash_timeouts_exceeded", `Aborted after ${state.bashTimeoutCount} bash timeouts in this attempt (cap ${maxBashTimeouts}).`);
26679
26713
  }
26714
+ } else if (event.type === "turn_end") {
26715
+ const msg = event.message;
26716
+ if (msg?.role === "assistant" && msg.usage) {
26717
+ usage.inputTokens += Math.max(0, msg.usage.input ?? 0);
26718
+ usage.outputTokens += Math.max(0, msg.usage.output ?? 0);
26719
+ const cr = Math.max(0, msg.usage.cacheRead ?? 0);
26720
+ const cw = Math.max(0, msg.usage.cacheWrite ?? 0);
26721
+ if (cr) usage.cacheReadTokens = (usage.cacheReadTokens ?? 0) + cr;
26722
+ if (cw) usage.cacheWriteTokens = (usage.cacheWriteTokens ?? 0) + cw;
26723
+ }
26724
+ const stopReason = msg?.stopReason ?? "end_turn";
26725
+ track(emit("turn_end", { stop_reason: stopReason }));
26726
+ if (maxTurns > 0 && stopReason !== "end_turn" && stopReason !== "aborted" && stopReason !== "error") {
26727
+ state.toolUseTurnCount += 1;
26728
+ if (state.toolUseTurnCount >= maxTurns) triggerCapAbort("max_turns_exceeded", `Aborted after ${state.toolUseTurnCount} tool-use turns (cap ${maxTurns}).`);
26729
+ }
26730
+ state.llmAbort = msg?.stopReason === "error";
26731
+ if (msg?.stopReason === "error") state.llmErrorMessage = typeof msg.errorMessage === "string" && msg.errorMessage.length > 0 ? msg.errorMessage : null;
26732
+ else state.llmErrorMessage = null;
26680
26733
  }
26681
- if (managed) await managed.vm.close();
26682
- if (workspace) try {
26683
- workspace.cleanup();
26734
+ };
26735
+ }
26736
+ /**
26737
+ * Resolve the attempt's structured output once the session has finished
26738
+ * cleanly (no run error / provider abort / cancel / cap). Three mutually
26739
+ * exclusive paths, in precedence order:
26740
+ *
26741
+ * 1. Submit tool captured a payload → trust it, compute its CID. A
26742
+ * canonicalization failure becomes `output_cid_compute_failed`.
26743
+ * 2. Submit tool registered but nothing captured → the exhausted-validation
26744
+ * failure wins if present, else `submit_output_missing` (recording the
26745
+ * `output_missing` counter so the never-called path is observable).
26746
+ * 3. No submit tool (legacy task type) → parse the trailing assistant text.
26747
+ *
26748
+ * Extracted from `executePiTask` so this precedence — the part a refactor is
26749
+ * most likely to silently reorder — is unit-tested directly. The caller
26750
+ * still owns the guard deciding whether output capture runs at all.
26751
+ *
26752
+ * @internal Exported for unit testing; not part of the package's public API.
26753
+ */
26754
+ async function captureAttemptOutput(deps) {
26755
+ const { taskType, model, input, assistantText, submitToolHandle, emit } = deps;
26756
+ const captured = submitToolHandle?.getCaptured() ?? null;
26757
+ if (captured) try {
26758
+ const outputCid = await computeJsonCid(captured);
26759
+ recordTaskOutputParseResult({
26760
+ taskType,
26761
+ model,
26762
+ code: "captured_via_tool"
26763
+ });
26764
+ return {
26765
+ output: captured,
26766
+ outputCid,
26767
+ error: null
26768
+ };
26769
+ } catch (err) {
26770
+ const error = {
26771
+ code: "output_cid_compute_failed",
26772
+ message: `Captured submit-tool output could not be canonicalized: ${err instanceof Error ? err.message : String(err)}`
26773
+ };
26774
+ recordTaskOutputParseResult({
26775
+ taskType,
26776
+ model,
26777
+ code: "output_cid_compute_failed"
26778
+ });
26779
+ await emit("error", {
26780
+ message: error.message,
26781
+ phase: "output_validation"
26782
+ });
26783
+ return {
26784
+ output: null,
26785
+ outputCid: null,
26786
+ error
26787
+ };
26788
+ }
26789
+ if (submitToolHandle) {
26790
+ const exhausted = submitToolHandle.getExhaustedValidationFailure();
26791
+ const error = exhausted ?? {
26792
+ code: "submit_output_missing",
26793
+ message: "Agent did not satisfy the promised submit-output criterion: no valid task submit tool call was captured before the session ended."
26794
+ };
26795
+ if (!exhausted) recordTaskOutputParseResult({
26796
+ taskType,
26797
+ model,
26798
+ code: "output_missing"
26799
+ });
26800
+ await emit("error", {
26801
+ message: error.message,
26802
+ phase: "output_validation"
26803
+ });
26804
+ return {
26805
+ output: null,
26806
+ outputCid: null,
26807
+ error
26808
+ };
26809
+ }
26810
+ const parsed = await parseStructuredTaskOutput(assistantText, taskType, {
26811
+ model,
26812
+ input
26813
+ });
26814
+ if (parsed.error) await emit("error", {
26815
+ message: parsed.error.message,
26816
+ phase: "output_validation"
26817
+ });
26818
+ return {
26819
+ output: parsed.output,
26820
+ outputCid: parsed.outputCid,
26821
+ error: parsed.error
26822
+ };
26823
+ }
26824
+ /**
26825
+ * Assemble the terminal `TaskOutput` for a clean-or-failed finish (cancel and
26826
+ * cap aborts are handled by the caller's earlier returns). Encapsulates the
26827
+ * failure-precedence ladder — runError → parseError → reporterError →
26828
+ * provider abort — so the ordering is unit-tested rather than buried in the
26829
+ * orchestrator. A provider abort with no captured diagnostic falls back to a
26830
+ * generic message.
26831
+ *
26832
+ * Errors are non-retryable EXCEPT a reporterError that both wins the ladder
26833
+ * and set `retryable: true` (a transient reporter failure, #1538).
26834
+ *
26835
+ * @internal Exported for unit testing; not part of the package's public API.
26836
+ */
26837
+ function buildAttemptResult(args) {
26838
+ const status = args.runError || args.llmAbort || args.parseError || args.reporterError ? "failed" : "completed";
26839
+ const errorCode = args.runError?.code ?? args.parseError?.code ?? args.reporterError?.code ?? (args.llmAbort ? "llm_api_error" : void 0);
26840
+ const errorMessage = args.runError?.message ?? args.parseError?.message ?? args.reporterError?.message ?? (args.llmAbort ? args.llmErrorMessage ?? "LLM API error during turn" : void 0);
26841
+ const errorRetryable = args.reporterError && errorCode === args.reporterError.code && errorMessage === args.reporterError.message ? args.reporterError.retryable ?? false : false;
26842
+ return {
26843
+ taskId: args.taskId,
26844
+ attemptN: args.attemptN,
26845
+ status,
26846
+ output: args.output,
26847
+ outputCid: args.outputCid,
26848
+ usage: args.usage,
26849
+ durationMs: args.durationMs,
26850
+ ...errorCode && errorMessage ? { error: {
26851
+ code: errorCode,
26852
+ message: errorMessage,
26853
+ retryable: errorRetryable
26854
+ } } : {}
26855
+ };
26856
+ }
26857
+ /**
26858
+ * Tear down one attempt's resources, in order: detach the cancel listener →
26859
+ * dispose the pi session → finalize+close the reporter → close the VM →
26860
+ * clean the workspace. Extracted from `executePiTask`'s `finally` so the
26861
+ * swallow-vs-log-vs-propagate policy is pinned by tests.
26862
+ *
26863
+ * Failure handling is deliberately asymmetric and preserved exactly:
26864
+ * `session.dispose()` throws are silently swallowed; reporter finalize/close
26865
+ * and workspace cleanup failures are logged but non-fatal (the task is about
26866
+ * to be reported anyway). `vm.close()` is the one teardown error allowed to
26867
+ * propagate: a leaked VM means a live microVM the host never reclaims, so its
26868
+ * failure must surface loudly rather than be logged and forgotten.
26869
+ *
26870
+ * @internal Exported for unit testing; not part of the package's public API.
26871
+ */
26872
+ async function cleanupAttempt(deps) {
26873
+ const log = deps.logError ?? ((m) => console.error(m));
26874
+ if (deps.cancelListener) deps.cancelSignal.removeEventListener("abort", deps.cancelListener);
26875
+ if (deps.session) try {
26876
+ deps.session.dispose();
26877
+ } catch {}
26878
+ if (deps.reporterOpen) {
26879
+ try {
26880
+ await deps.reporter.finalize(deps.finalUsage);
26881
+ } catch (err) {
26882
+ const detail = err instanceof Error ? err.message : String(err);
26883
+ log(`executePiTask: reporter.finalize() failed for task ${deps.taskId} attempt ${deps.attemptN}: ${detail}`);
26884
+ }
26885
+ try {
26886
+ await deps.reporter.close();
26684
26887
  } catch (err) {
26685
26888
  const detail = err instanceof Error ? err.message : String(err);
26686
- console.error(`executePiTask: workspace cleanup failed for task ${task.id} attempt ${attemptN}: ${detail}`);
26889
+ log(`executePiTask: reporter.close() failed for task ${deps.taskId} attempt ${deps.attemptN}: ${detail}`);
26687
26890
  }
26688
26891
  }
26892
+ if (deps.managed) await deps.managed.vm.close();
26893
+ if (deps.workspace) try {
26894
+ deps.workspace.cleanup();
26895
+ } catch (err) {
26896
+ const detail = err instanceof Error ? err.message : String(err);
26897
+ log(`executePiTask: workspace cleanup failed for task ${deps.taskId} attempt ${deps.attemptN}: ${detail}`);
26898
+ }
26689
26899
  }
26690
26900
  function applyExecutionPlanSandboxOverrides(sandboxConfig, executionPlan) {
26691
26901
  const shadowWrites = executionPlan?.workspaceAttachment?.shadowWrites;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/pi-extension",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "description": "MoltNet pi extension — sandboxed tool execution in Gondolin VMs with MoltNet identity and persistent memory",
6
6
  "keywords": [
@@ -36,8 +36,8 @@
36
36
  "@earendil-works/gondolin": "^0.9.1",
37
37
  "@opentelemetry/api": "^1.9.0",
38
38
  "typebox": "^1.2.8",
39
- "@themoltnet/agent-runtime": "0.34.0",
40
- "@themoltnet/sdk": "0.118.0"
39
+ "@themoltnet/agent-runtime": "0.35.0",
40
+ "@themoltnet/sdk": "0.119.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@earendil-works/pi-coding-agent": ">=0.74.0",