@code-yeongyu/senpi-agent-core 2026.9.30 → 2026.10.1-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.
Files changed (64) hide show
  1. package/README.md +49 -20
  2. package/dist/agent-loop.d.ts +27 -3
  3. package/dist/agent-loop.js +192 -56
  4. package/dist/agent.d.ts +21 -7
  5. package/dist/agent.js +38 -18
  6. package/dist/harness/execution/tools.d.ts +1 -1
  7. package/dist/harness/execution/tools.js +0 -2
  8. package/dist/harness/messages.js +1 -0
  9. package/dist/harness/pico3/bash.d.ts +10 -0
  10. package/dist/harness/pico3/bash.js +25 -0
  11. package/dist/harness/pico3/bounded.d.ts +21 -0
  12. package/dist/harness/pico3/bounded.js +95 -0
  13. package/dist/harness/pico3/chord.d.ts +41 -0
  14. package/dist/harness/pico3/chord.js +147 -0
  15. package/dist/harness/pico3/context.d.ts +16 -0
  16. package/dist/harness/pico3/context.js +88 -0
  17. package/dist/harness/pico3/harness.d.ts +198 -0
  18. package/dist/harness/pico3/harness.js +645 -0
  19. package/dist/harness/pico3/hooks.d.ts +10 -0
  20. package/dist/harness/pico3/hooks.js +33 -0
  21. package/dist/harness/pico3/index.d.ts +19 -0
  22. package/dist/harness/pico3/index.js +15 -0
  23. package/dist/harness/pico3/jsonl.d.ts +58 -0
  24. package/dist/harness/pico3/jsonl.js +322 -0
  25. package/dist/harness/pico3/kinds/collapse.d.ts +49 -0
  26. package/dist/harness/pico3/kinds/collapse.js +191 -0
  27. package/dist/harness/pico3/kinds/entries.d.ts +78 -0
  28. package/dist/harness/pico3/kinds/entries.js +13 -0
  29. package/dist/harness/pico3/kinds/frames.d.ts +9 -0
  30. package/dist/harness/pico3/kinds/frames.js +75 -0
  31. package/dist/harness/pico3/kinds/generation.d.ts +94 -0
  32. package/dist/harness/pico3/kinds/generation.js +504 -0
  33. package/dist/harness/pico3/kinds/job.d.ts +44 -0
  34. package/dist/harness/pico3/kinds/job.js +143 -0
  35. package/dist/harness/pico3/kinds/plugin.d.ts +15 -0
  36. package/dist/harness/pico3/kinds/plugin.js +35 -0
  37. package/dist/harness/pico3/kinds/post-tools.d.ts +22 -0
  38. package/dist/harness/pico3/kinds/post-tools.js +145 -0
  39. package/dist/harness/pico3/kinds/task-api.d.ts +3 -0
  40. package/dist/harness/pico3/kinds/task-api.js +45 -0
  41. package/dist/harness/pico3/kinds/tool.d.ts +43 -0
  42. package/dist/harness/pico3/kinds/tool.js +373 -0
  43. package/dist/harness/pico3/legacy-tracker.d.ts +11 -0
  44. package/dist/harness/pico3/legacy-tracker.js +39 -0
  45. package/dist/harness/pico3/membrane.d.ts +23 -0
  46. package/dist/harness/pico3/membrane.js +140 -0
  47. package/dist/harness/pico3/memory.d.ts +53 -0
  48. package/dist/harness/pico3/memory.js +265 -0
  49. package/dist/harness/pico3/scheduler.d.ts +49 -0
  50. package/dist/harness/pico3/scheduler.js +437 -0
  51. package/dist/harness/pico3/session.d.ts +264 -0
  52. package/dist/harness/pico3/session.js +1328 -0
  53. package/dist/harness/pico3/system.d.ts +126 -0
  54. package/dist/harness/pico3/system.js +244 -0
  55. package/dist/harness/pico3/types.d.ts +973 -0
  56. package/dist/harness/pico3/types.js +94 -0
  57. package/dist/harness/pico3/view.d.ts +34 -0
  58. package/dist/harness/pico3/view.js +404 -0
  59. package/dist/harness/runtime/drive/tool-placement.js +2 -27
  60. package/dist/harness/telemetry.d.ts +36 -36
  61. package/dist/harness/tools/image.js +1 -1
  62. package/dist/proxy.d.ts +2 -2
  63. package/dist/types.d.ts +111 -34
  64. package/package.json +9 -5
package/README.md CHANGED
@@ -130,27 +130,54 @@ The `beforeToolCall` hook runs after `tool_execution_start` and validated argume
130
130
 
131
131
  Tools, blocked `beforeToolCall` results, and `afterToolCall` overrides can return `terminate: true` to hint that the automatic follow-up LLM call should be skipped. The loop only stops early when every finalized tool result in that batch sets `terminate: true`. Mixed batches continue normally.
132
132
 
133
- The `Agent` class accepts `shouldStopAfterTurn` in `AgentOptions`. Low-level loop callers can set the same hook in `AgentLoopConfig`:
133
+ When you use the `Agent` class, assistant `message_end` processing is treated as a barrier before tool preflight begins. That means `beforeToolCall` sees agent state that already includes the assistant message that requested the tool call.
134
+
135
+ ### Request preparation and turn finalization
136
+
137
+ `prepareRequest` runs immediately before every conversational provider request, including the first. Use it to install canonical persisted context after pending input has been emitted:
134
138
 
135
139
  ```typescript
136
- const stream = agentLoop(
137
- prompts,
138
- context,
139
- {
140
- model,
141
- convertToLlm,
142
- shouldStopAfterTurn: async ({ message, toolResults, context, newMessages }) => {
143
- return shouldCompactBeforeNextTurn(context.messages);
144
- },
145
- },
146
- undefined,
147
- models.streamSimple.bind(models),
148
- );
140
+ agent.prepareRequest = async ({ context }) => ({
141
+ context: { ...context, messages: await session.loadModelContext() },
142
+ });
149
143
  ```
150
144
 
151
- `shouldStopAfterTurn` runs after `turn_end` is emitted and after the assistant response and any tool executions have completed normally. If it returns `true`, the loop emits `agent_end` and exits before polling steering or follow-up queues, and before starting another LLM call. It does not abort the provider stream, does not cancel running tools, and does not alter the assistant message stop reason. The `AgentOptions` callback also receives the active run's `AbortSignal` as its second argument.
145
+ `prepareRequest` does not poll queues. Steering queued while it runs waits for the next normal steering poll.
152
146
 
153
- When you use the `Agent` class, assistant `message_end` processing is treated as a barrier before tool preflight begins. That means `beforeToolCall` sees agent state that already includes the assistant message that requested the tool call.
147
+ `finishTurn` runs after the assistant message and all tool results are finalized, but before `turn_end`. It runs for normal, error, and aborted responses:
148
+
149
+ ```typescript
150
+ agent.finishTurn = async ({ message }) => {
151
+ if (message.stopReason === "error" || message.stopReason === "aborted") return;
152
+ if (shouldEndRun(message)) return { action: "end" };
153
+ return needsAnotherResponse(message) ? { action: "continue" } : undefined;
154
+ };
155
+ ```
156
+
157
+ Returning `undefined` keeps normal scheduling. `{ action: "end" }` stops right after `turn_end`, before polling steering or follow-up queues or preparing another request. On a normal response, `{ action: "continue" }` makes sure one more provider request happens: tool results, steering, or a follow-up that already cause that request satisfy it, and otherwise the loop makes one context-only request. Error and aborted responses stay hard exits, so their decisions are ignored. `finishTurn` runs again after the next request, so returning `{ action: "continue" }` unconditionally loops forever. Low-level loop callers set the same hooks in `AgentLoopConfig`.
158
+
159
+ To migrate from the removed `shouldStopAfterTurn`, return `{ action: "end" }` and guard error and aborted responses to keep the old normal-response-only invocation:
160
+
161
+ ```typescript
162
+ finishTurn: async (turn, signal) => {
163
+ if (turn.message.stopReason === "error" || turn.message.stopReason === "aborted") return;
164
+ return (await shouldStop(turn, signal)) ? { action: "end" } : undefined;
165
+ },
166
+ ```
167
+
168
+ Each provider turn follows this lifecycle:
169
+
170
+ ```text
171
+ selected input events
172
+ → prepareRequest
173
+ → provider response
174
+ → tool results
175
+ → finishTurn
176
+ → turn_end
177
+ → existing continuation scheduling or agent_end
178
+ ```
179
+
180
+ `agent.peekQueuedMessages()` previews the next queue-selected batch, respecting the queue modes, without consuming it.
154
181
 
155
182
  ### continue() Event Sequence
156
183
 
@@ -247,9 +274,10 @@ const agent = new Agent({
247
274
  }
248
275
  },
249
276
 
250
- // Stop gracefully after a completed turn, before queued messages are polled.
251
- shouldStopAfterTurn: async ({ context }, signal) => {
252
- return shouldCompactBeforeNextTurn(context.messages, signal);
277
+ // End the run gracefully after a completed turn, before queued messages are polled.
278
+ finishTurn: async ({ message, context }, signal) => {
279
+ if (message.stopReason === "error" || message.stopReason === "aborted") return;
280
+ return (await shouldCompactBeforeNextTurn(context.messages, signal)) ? { action: "end" } : undefined;
253
281
  },
254
282
 
255
283
  // Custom thinking budgets for token-based providers
@@ -316,7 +344,8 @@ agent.state.tools = [myTool];
316
344
  agent.toolExecution = "sequential";
317
345
  agent.beforeToolCall = async ({ toolCall }) => undefined;
318
346
  agent.afterToolCall = async ({ toolCall, result }) => undefined;
319
- agent.shouldStopAfterTurn = async ({ context }) => shouldCompactBeforeNextTurn(context.messages);
347
+ agent.prepareRequest = async ({ context }) => undefined;
348
+ agent.finishTurn = async () => undefined;
320
349
  agent.state.messages = newMessages; // top-level array is copied
321
350
  agent.state.messages.push(message);
322
351
  agent.reset();
@@ -2,9 +2,9 @@
2
2
  * Agent loop that works with AgentMessage throughout.
3
3
  * Transforms to Message[] only at the LLM call boundary.
4
4
  */
5
- import { type Context, EventStream } from "@earendil-works/pi-ai";
5
+ import { type AssistantMessage, EventStream, type TranscriptContext } from "@earendil-works/pi-ai";
6
6
  import { prepareAgentToolCallArguments } from "./tool-arguments.ts";
7
- import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool, AgentToolCall, StreamFn } from "./types.ts";
7
+ import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool, AgentToolCall, AgentToolCallOutcome, AgentToolResult, StreamFn } from "./types.ts";
8
8
  export type AgentEventSink = (event: AgentEvent) => Promise<void> | void;
9
9
  /**
10
10
  * Start an agent loop with a new prompt message.
@@ -26,7 +26,10 @@ export declare class StreamStartTimeoutError extends Error {
26
26
  constructor(timeoutMs: number);
27
27
  }
28
28
  /** Build the provider context using the same transform and conversion pipeline as an agent request. */
29
- export declare function buildProviderContext(context: AgentContext, config: Pick<AgentLoopConfig, "convertToLlm" | "transformContext"> & Partial<Pick<AgentLoopConfig, "model">>, signal?: AbortSignal): Promise<Context>;
29
+ export declare function buildProviderContext(context: AgentContext, config: Pick<AgentLoopConfig, "convertToLlm" | "transformContext"> & Partial<Pick<AgentLoopConfig, "model">>, signal?: AbortSignal): Promise<TranscriptContext>;
30
+ /** The `beforeToolCall` and `afterToolCall` hooks of {@link AgentLoopConfig}. */
31
+ export type ToolCallHooks = Pick<AgentLoopConfig, "beforeToolCall" | "afterToolCall">;
32
+ type ToolUpdateSink = (partialResult: AgentToolResult<any>) => Promise<void> | void;
30
33
  export interface PreparedAgentToolCall {
31
34
  toolCall: AgentToolCall;
32
35
  tool: AgentTool;
@@ -34,4 +37,25 @@ export interface PreparedAgentToolCall {
34
37
  }
35
38
  export { prepareAgentToolCallArguments };
36
39
  export declare function prepareAgentToolCall(tool: AgentTool, toolCall: AgentToolCall): PreparedAgentToolCall;
40
+ /** Options for {@link runToolCall}. */
41
+ export interface RunToolCallOptions extends ToolCallHooks {
42
+ /** Tools the call resolves against. */
43
+ tools: readonly AgentTool<any>[];
44
+ /** Passed to the hooks as the message that issued the call. */
45
+ assistantMessage: AssistantMessage;
46
+ /** Passed to the hooks as the current agent context. */
47
+ context: AgentContext;
48
+ signal?: AbortSignal;
49
+ onUpdate?: ToolUpdateSink;
50
+ }
51
+ /**
52
+ * Run one tool call through the same steps as a model-issued call: argument preparation, schema
53
+ * validation, `beforeToolCall`, execution, and `afterToolCall`. Emits no events and adds no
54
+ * messages. Tools that call other tools use this so the hooks (for example permission checks)
55
+ * apply to those calls too.
56
+ *
57
+ * Never rejects for tool failures: unknown tools, validation errors, blocked calls, and thrown
58
+ * errors come back as `isError: true`.
59
+ */
60
+ export declare function runToolCall(toolCall: AgentToolCall, options: RunToolCallOptions): Promise<AgentToolCallOutcome>;
37
61
  //# sourceMappingURL=agent-loop.d.ts.map
@@ -2,7 +2,7 @@
2
2
  * Agent loop that works with AgentMessage throughout.
3
3
  * Transforms to Message[] only at the LLM call boundary.
4
4
  */
5
- import { EventStream, isCursorExecResolved, supportsAllowedToolChoice, validateToolArguments, } from "@earendil-works/pi-ai";
5
+ import { createInitialSystemMessage, EventStream, getCurrentTools, getToolStateChanges, isCursorExecResolved, normalizeContext, supportsAllowedToolChoice, validateToolArguments, } from "@earendil-works/pi-ai";
6
6
  import { createTerminalFailureAssistantMessage, demoteToolUseWithoutToolCalls, isStreamIdleTimeoutError, normalizeTerminalAssistantMessage, promoteStopWithPendingToolCalls, shouldFinalizeIdleAsStop, shouldTerminateAssistantTurn, } from "./assistant-terminal-state.js";
7
7
  import { getDefaultStreamFn, withEmptyAssistantRecovery } from "./stream-fn.js";
8
8
  import { prepareAgentToolCallArguments } from "./tool-arguments.js";
@@ -44,16 +44,17 @@ export function agentLoopContinue(context, config, signal, streamFn) {
44
44
  return stream;
45
45
  }
46
46
  export async function runAgentLoop(prompts, context, config, emit, signal, streamFn) {
47
- const newMessages = [...prompts];
47
+ const initialMessages = declareToolChanges(context, prompts, config.model);
48
+ const newMessages = [...initialMessages];
48
49
  const currentContext = {
49
50
  ...context,
50
- messages: [...context.messages, ...prompts],
51
+ messages: [...context.messages, ...initialMessages],
51
52
  };
52
53
  await emit({ type: "agent_start" });
53
54
  await emit({ type: "turn_start" });
54
- for (const prompt of prompts) {
55
- await emit({ type: "message_start", message: prompt });
56
- await emit({ type: "message_end", message: prompt });
55
+ for (const message of initialMessages) {
56
+ await emit({ type: "message_start", message });
57
+ await emit({ type: "message_end", message });
57
58
  }
58
59
  await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());
59
60
  return newMessages;
@@ -100,6 +101,10 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
100
101
  let firstProviderRequest = true;
101
102
  let drainedTerminatingQueue;
102
103
  let turnStartAlreadyEmitted = false;
104
+ // Set by a `finishTurn` `{ action: "continue" }`; cleared once a natural request is scheduled.
105
+ let explicitContinuation = false;
106
+ // Messages from `prepareNextTurn`, appended before the next provider request.
107
+ let preparedMessages = [];
103
108
  const refreshTerminatingQueueDrain = async () => {
104
109
  if (!drainedTerminatingQueue || !config.restorePendingMessages)
105
110
  return;
@@ -129,21 +134,29 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
129
134
  }
130
135
  if (drainedTerminatingQueue) {
131
136
  await refreshTerminatingQueueDrain();
132
- if (pendingMessages.length === 0) {
137
+ if (pendingMessages.length === 0 && !explicitContinuation) {
133
138
  await emit({ type: "agent_end", messages: newMessages });
134
139
  return;
135
140
  }
136
141
  drainedTerminatingQueue = undefined;
137
142
  }
138
- // Process pending messages (inject before next assistant response)
139
- if (pendingMessages.length > 0) {
140
- for (const message of pendingMessages) {
141
- await emit({ type: "message_start", message });
142
- await emit({ type: "message_end", message });
143
- currentContext.messages.push(message);
144
- newMessages.push(message);
145
- }
146
- pendingMessages = [];
143
+ // Process prepared and queued messages before the next assistant response.
144
+ for (const message of declareToolChanges(currentContext, [...preparedMessages, ...pendingMessages], config.model)) {
145
+ await emit({ type: "message_start", message });
146
+ await emit({ type: "message_end", message });
147
+ currentContext.messages.push(message);
148
+ newMessages.push(message);
149
+ }
150
+ preparedMessages = [];
151
+ pendingMessages = [];
152
+ const requestUpdate = await config.prepareRequest?.({
153
+ context: currentContext,
154
+ model: config.model,
155
+ thinkingLevel: config.reasoning ?? "off",
156
+ }, signal);
157
+ if (requestUpdate) {
158
+ currentContext = requestUpdate.context ?? currentContext;
159
+ config = applyLoopUpdate(config, requestUpdate);
147
160
  }
148
161
  // Stream assistant response
149
162
  const isInitialProviderRequest = firstProviderRequest;
@@ -168,6 +181,8 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
168
181
  toolResults.push(result);
169
182
  }
170
183
  if (shouldTerminateAssistantTurn(message)) {
184
+ // Hard exit: the decision is ignored, but the hook still sees the finished turn before turn_end.
185
+ await config.finishTurn?.({ message, toolResults, context: currentContext, newMessages }, signal);
171
186
  await emit({ type: "turn_end", message, toolResults });
172
187
  await emit({ type: "agent_end", messages: newMessages });
173
188
  return;
@@ -191,21 +206,23 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
191
206
  newMessages.push(result);
192
207
  }
193
208
  }
194
- await emit({ type: "turn_end", message, toolResults });
195
- if (signal?.aborted) {
196
- await emit({ type: "agent_end", messages: newMessages });
197
- return;
198
- }
199
209
  const nextTurnContext = {
200
210
  message,
201
211
  toolResults,
202
212
  context: currentContext,
203
213
  newMessages,
204
214
  };
205
- if (await config.shouldStopAfterTurn?.(nextTurnContext)) {
215
+ const decision = await config.finishTurn?.(nextTurnContext, signal);
216
+ await emit({ type: "turn_end", message, toolResults });
217
+ if (signal?.aborted) {
218
+ await emit({ type: "agent_end", messages: newMessages });
219
+ return;
220
+ }
221
+ if (decision?.action === "end") {
206
222
  await emit({ type: "agent_end", messages: newMessages });
207
223
  return;
208
224
  }
225
+ explicitContinuation = decision?.action === "continue";
209
226
  if (toolBatchTerminated) {
210
227
  pendingMessages = (await config.getSteeringMessages?.()) || [];
211
228
  if (pendingMessages.length > 0)
@@ -215,14 +232,16 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
215
232
  if (pendingMessages.length > 0)
216
233
  drainedTerminatingQueue = "followUp";
217
234
  }
218
- if (pendingMessages.length === 0) {
235
+ if (pendingMessages.length === 0 && !explicitContinuation) {
219
236
  await emit({ type: "agent_end", messages: newMessages });
220
237
  return;
221
238
  }
222
- // Give queue owners a boundary before preparation refreshes the drained
223
- // snapshot, so a clear or replacement wins before admission.
224
- await emit({ type: "turn_start" });
225
- turnStartAlreadyEmitted = true;
239
+ if (pendingMessages.length > 0) {
240
+ // Give queue owners a boundary before preparation refreshes the drained
241
+ // snapshot, so a clear or replacement wins before admission.
242
+ await emit({ type: "turn_start" });
243
+ turnStartAlreadyEmitted = true;
244
+ }
226
245
  }
227
246
  let nextTurnSnapshot;
228
247
  try {
@@ -235,19 +254,8 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
235
254
  }
236
255
  if (nextTurnSnapshot) {
237
256
  currentContext = nextTurnSnapshot.context ?? currentContext;
238
- config = {
239
- ...config,
240
- model: nextTurnSnapshot.model ?? config.model,
241
- reasoning: nextTurnSnapshot.thinkingLevel === undefined
242
- ? config.reasoning
243
- : nextTurnSnapshot.thinkingLevel === "off"
244
- ? undefined
245
- : nextTurnSnapshot.thinkingLevel,
246
- thinkingSelection: nextTurnSnapshot.thinkingSelection === undefined
247
- ? config.thinkingSelection
248
- : (nextTurnSnapshot.thinkingSelection ?? undefined),
249
- abortServerSideFallback: nextTurnSnapshot.abortServerSideFallback ?? config.abortServerSideFallback,
250
- };
257
+ preparedMessages = nextTurnSnapshot.messages ?? [];
258
+ config = applyLoopUpdate(config, nextTurnSnapshot);
251
259
  }
252
260
  if (signal?.aborted) {
253
261
  if (drainedTerminatingQueue)
@@ -257,7 +265,7 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
257
265
  }
258
266
  if (drainedTerminatingQueue) {
259
267
  await refreshTerminatingQueueDrain();
260
- if (pendingMessages.length === 0) {
268
+ if (pendingMessages.length === 0 && !explicitContinuation) {
261
269
  await emit({ type: "agent_end", messages: newMessages });
262
270
  return;
263
271
  }
@@ -266,19 +274,115 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
266
274
  if (!toolBatchTerminated) {
267
275
  pendingMessages = (await config.getSteeringMessages?.()) || [];
268
276
  }
277
+ if (hasMoreToolCalls || pendingMessages.length > 0) {
278
+ explicitContinuation = false;
279
+ }
269
280
  }
270
281
  // Agent would stop here. Check for follow-up messages.
271
282
  const followUpMessages = (await config.getFollowUpMessages?.()) || [];
272
283
  if (followUpMessages.length > 0) {
273
284
  // Set as pending so inner loop processes them
285
+ explicitContinuation = false;
274
286
  pendingMessages = followUpMessages;
275
287
  continue;
276
288
  }
289
+ // No natural request was selected, so fulfill the continuation decision with one context-only turn.
290
+ if (explicitContinuation) {
291
+ explicitContinuation = false;
292
+ continue;
293
+ }
277
294
  // No more messages, exit
278
295
  break;
279
296
  }
280
297
  await emit({ type: "agent_end", messages: newMessages });
281
298
  }
299
+ /** Apply a `prepareNextTurn` or `prepareRequest` update to the loop config for this and later requests. */
300
+ function applyLoopUpdate(config, update) {
301
+ return {
302
+ ...config,
303
+ model: update.model ?? config.model,
304
+ reasoning: update.thinkingLevel === undefined
305
+ ? config.reasoning
306
+ : update.thinkingLevel === "off"
307
+ ? undefined
308
+ : update.thinkingLevel,
309
+ thinkingSelection: update.thinkingSelection === undefined ? config.thinkingSelection : (update.thinkingSelection ?? undefined),
310
+ abortServerSideFallback: update.abortServerSideFallback ?? config.abortServerSideFallback,
311
+ };
312
+ }
313
+ /**
314
+ * Declare tool loadout changes to the model.
315
+ *
316
+ * The provider tools (senpi#2095 `providerTools`) are what the model may call; the transcript's system
317
+ * messages declare them. Before each request the difference becomes `toolsAdded` and `toolsRemoved` on a
318
+ * system message. When a pending system message exists, its tool fields are treated as intent and replaced
319
+ * with the delta between the committed transcript and the provider tools, so replay always yields exactly
320
+ * those tools. Otherwise a new system message is inserted before the first non-system pending message.
321
+ *
322
+ * Fork: `buildProviderContext` folds `context.systemPrompt` and the provider tools into the leading system
323
+ * message through `normalizeContext()`, so that shorthand declaration counts as committed. A context that
324
+ * carries its tools only through that shorthand therefore never gains a duplicate declaration.
325
+ */
326
+ function declareToolChanges(context, pendingMessages, model) {
327
+ let systemIndex = -1;
328
+ for (let i = pendingMessages.length - 1; i >= 0; i--) {
329
+ if (pendingMessages[i].role === "system") {
330
+ systemIndex = i;
331
+ break;
332
+ }
333
+ }
334
+ const pending = pendingMessages[systemIndex];
335
+ const baseline = pending
336
+ ? pendingMessages.map((message, index) => index === systemIndex ? withToolChanges(pending, NO_CHANGES) : message)
337
+ : pendingMessages;
338
+ const declared = (providerTools(context, model).tools ?? []).map(toProviderToolDeclaration);
339
+ const shorthand = createInitialSystemMessage(context.systemPrompt, declared);
340
+ const committed = shorthand ? [shorthand, ...context.messages, ...baseline] : [...context.messages, ...baseline];
341
+ const delta = getToolStateChanges(getCurrentTools(committed), declared);
342
+ // `getToolStateChanges` compares through upstream `toToolDeclaration`, which drops the fork `freeform` field;
343
+ // announce the fork declaration itself so a delta-added tool keeps its full provider shape.
344
+ const declaredByName = new Map(declared.map((tool) => [tool.name, tool]));
345
+ const changes = {
346
+ toolsAdded: delta.toolsAdded.map((tool) => declaredByName.get(tool.name) ?? tool),
347
+ toolsRemoved: delta.toolsRemoved,
348
+ };
349
+ const unchanged = changes.toolsAdded.length === 0 && changes.toolsRemoved.length === 0;
350
+ if (pending) {
351
+ // Keep the caller's message object when it already declares no tool changes.
352
+ if (unchanged && !pending.toolsAdded?.length && !pending.toolsRemoved?.length)
353
+ return pendingMessages;
354
+ return baseline.map((message, index) => (index === systemIndex ? withToolChanges(pending, changes) : message));
355
+ }
356
+ if (unchanged)
357
+ return pendingMessages;
358
+ const update = withToolChanges({ role: "system", content: "", timestamp: Date.now() }, changes);
359
+ const insertIndex = pendingMessages.findIndex((message) => message.role !== "system");
360
+ const index = insertIndex === -1 ? pendingMessages.length : insertIndex;
361
+ return [...pendingMessages.slice(0, index), update, ...pendingMessages.slice(index)];
362
+ }
363
+ const NO_CHANGES = { toolsAdded: [], toolsRemoved: [] };
364
+ /**
365
+ * The provider-facing declaration of a tool: every `Tool` field (fork `freeform` included) and nothing executable,
366
+ * so transcripts stay cloneable and serializable. Upstream `toToolDeclaration` would drop `freeform`.
367
+ */
368
+ function toProviderToolDeclaration(tool) {
369
+ return {
370
+ name: tool.name,
371
+ description: tool.description,
372
+ parameters: tool.parameters,
373
+ ...(tool.freeform === undefined ? {} : { freeform: tool.freeform }),
374
+ ...(tool.constrainedSampling === undefined ? {} : { constrainedSampling: tool.constrainedSampling }),
375
+ };
376
+ }
377
+ /** Copy a system message with its tool fields replaced by `changes`; empty lists omit the field. */
378
+ function withToolChanges(message, { toolsAdded, toolsRemoved }) {
379
+ const { toolsAdded: _added, toolsRemoved: _removed, ...rest } = message;
380
+ return {
381
+ ...rest,
382
+ ...(toolsAdded.length > 0 ? { toolsAdded } : {}),
383
+ ...(toolsRemoved.length > 0 ? { toolsRemoved } : {}),
384
+ };
385
+ }
282
386
  /**
283
387
  * senpi#2095: a model that accepts an allowed-tools restriction receives every declared tool plus the
284
388
  * callable subset by name; any other model receives the callable tools alone, as before.
@@ -300,11 +404,13 @@ export async function buildProviderContext(context, config, signal) {
300
404
  let messages = context.messages;
301
405
  if (config.transformContext)
302
406
  messages = await config.transformContext(messages, signal);
303
- return {
407
+ const { tools, activeToolNames } = providerTools(context, config.model);
408
+ return normalizeContext({
304
409
  systemPrompt: context.systemPrompt,
305
410
  messages: await config.convertToLlm(messages),
306
- ...providerTools(context, config.model),
307
- };
411
+ ...(tools === undefined ? {} : { tools: tools.map(toProviderToolDeclaration) }),
412
+ ...(activeToolNames === undefined ? {} : { activeToolNames }),
413
+ });
308
414
  }
309
415
  /**
310
416
  * Stream an assistant response from the LLM.
@@ -367,6 +473,8 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
367
473
  }
368
474
  : {}),
369
475
  });
476
+ // Record the requested level, whichever stream function answered.
477
+ const result = async () => Object.assign(await response.result(), { thinkingLevel: config.reasoning ?? "off" });
370
478
  const iterator = response[Symbol.asyncIterator]();
371
479
  const eventReader = createAssistantEventReader(iterator, streamIdleTimeoutMs, requestAbortController.signal, (error) => requestAbortController.abort(error), config.streamStartTimeoutMs, response);
372
480
  try {
@@ -423,7 +531,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
423
531
  break;
424
532
  case "done":
425
533
  case "error": {
426
- const finalMessage = normalizeTerminalAssistantMessage(await response.result(), event);
534
+ const finalMessage = normalizeTerminalAssistantMessage(await result(), event);
427
535
  propagateThinkingTiming(finalMessage);
428
536
  if (addedPartial) {
429
537
  context.messages[context.messages.length - 1] = finalMessage;
@@ -446,7 +554,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
446
554
  finally {
447
555
  eventReader.dispose();
448
556
  }
449
- const finalMessage = await response.result();
557
+ const finalMessage = await result();
450
558
  propagateThinkingTiming(finalMessage);
451
559
  if (addedPartial) {
452
560
  context.messages[context.messages.length - 1] = finalMessage;
@@ -672,7 +780,7 @@ async function executeToolCallsSequential(currentContext, assistantMessage, tool
672
780
  };
673
781
  }
674
782
  else {
675
- const executed = await executePreparedToolCall(preparation, signal, emit);
783
+ const executed = await executePreparedToolCall(preparation, signal, emitToolExecutionUpdate(preparation.toolCall, emit));
676
784
  finalized = await finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, signal);
677
785
  }
678
786
  await emitToolExecutionEnd(finalized, emit);
@@ -767,7 +875,7 @@ async function runPreparedToolCall(currentContext, assistantMessage, preparation
767
875
  isError: preparation.isError,
768
876
  };
769
877
  }
770
- const executed = await executePreparedToolCall(preparation, signal, emit);
878
+ const executed = await executePreparedToolCall(preparation, signal, emitToolExecutionUpdate(preparation.toolCall, emit));
771
879
  return finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, signal);
772
880
  }
773
881
  function isSequentialToolCall(currentContext, toolCall) {
@@ -884,20 +992,42 @@ function abortReleasePromise(signal) {
884
992
  signal.addEventListener("abort", () => resolve(ABORTED), { once: true });
885
993
  });
886
994
  }
887
- async function executePreparedToolCall(prepared, signal, emit) {
995
+ function emitToolExecutionUpdate(toolCall, emit) {
996
+ return (partialResult) => emit({
997
+ type: "tool_execution_update",
998
+ toolCallId: toolCall.id,
999
+ toolName: toolCall.name,
1000
+ args: toolCall.arguments,
1001
+ partialResult,
1002
+ });
1003
+ }
1004
+ /**
1005
+ * Run one tool call through the same steps as a model-issued call: argument preparation, schema
1006
+ * validation, `beforeToolCall`, execution, and `afterToolCall`. Emits no events and adds no
1007
+ * messages. Tools that call other tools use this so the hooks (for example permission checks)
1008
+ * apply to those calls too.
1009
+ *
1010
+ * Never rejects for tool failures: unknown tools, validation errors, blocked calls, and thrown
1011
+ * errors come back as `isError: true`.
1012
+ */
1013
+ export async function runToolCall(toolCall, options) {
1014
+ const { assistantMessage, context, signal } = options;
1015
+ const tool = options.tools.find((candidate) => candidate.name === toolCall.name);
1016
+ const preparation = await prepareToolCall(context, assistantMessage, toolCall, tool, options, signal);
1017
+ if (preparation.kind === "immediate") {
1018
+ return { toolCall, result: preparation.result, isError: preparation.isError };
1019
+ }
1020
+ const executed = await executePreparedToolCall(preparation, signal, options.onUpdate ?? (() => { }));
1021
+ return finalizeExecutedToolCall(context, assistantMessage, preparation, executed, options, signal);
1022
+ }
1023
+ async function executePreparedToolCall(prepared, signal, onUpdate) {
888
1024
  const updateEvents = [];
889
1025
  let acceptingUpdates = true;
890
1026
  try {
891
1027
  const execution = prepared.tool.execute(prepared.toolCall.id, prepared.args, signal, (partialResult) => {
892
1028
  if (!acceptingUpdates)
893
1029
  return;
894
- updateEvents.push(Promise.resolve(emit({
895
- type: "tool_execution_update",
896
- toolCallId: prepared.toolCall.id,
897
- toolName: prepared.toolCall.name,
898
- args: prepared.toolCall.arguments,
899
- partialResult,
900
- })));
1030
+ updateEvents.push(Promise.resolve(onUpdate(partialResult)));
901
1031
  });
902
1032
  const abortRelease = abortReleasePromise(signal);
903
1033
  const settled = abortRelease ? await Promise.race([execution, abortRelease]) : await execution;
@@ -937,6 +1067,8 @@ async function finalizeExecutedToolCall(currentContext, assistantMessage, prepar
937
1067
  context: currentContext,
938
1068
  }, signal);
939
1069
  if (afterResult) {
1070
+ // Structured content not replaced along with the content may no longer match it.
1071
+ const structuredContent = afterResult.structuredContent ?? (afterResult.content ? undefined : result.structuredContent);
940
1072
  result = {
941
1073
  ...result,
942
1074
  content: afterResult.content ?? result.content,
@@ -944,6 +1076,10 @@ async function finalizeExecutedToolCall(currentContext, assistantMessage, prepar
944
1076
  usage: afterResult.usage ?? result.usage,
945
1077
  terminate: afterResult.terminate ?? result.terminate,
946
1078
  };
1079
+ if (structuredContent === undefined)
1080
+ delete result.structuredContent;
1081
+ else
1082
+ result.structuredContent = structuredContent;
947
1083
  isError = afterResult.isError ?? isError;
948
1084
  }
949
1085
  }
package/dist/agent.d.ts CHANGED
@@ -1,18 +1,25 @@
1
- import { type Context, type ImageContent, type Message, type SimpleStreamOptions, type ThinkingBudgets, type Transport } from "@earendil-works/pi-ai";
2
- import type { AfterToolCallContext, AfterToolCallResult, AgentContext, AgentEvent, AgentLoopConfig, AgentLoopTurnUpdate, AgentMessage, AgentState, BeforeToolCallContext, BeforeToolCallResult, PrepareNextTurnContext, QueueMode, ShouldStopAfterTurnContext, StreamFn, ToolExecutionMode } from "./types.ts";
1
+ import { type ImageContent, type Message, type SimpleStreamOptions, type ThinkingBudgets, type TranscriptContext, type Transport } from "@earendil-works/pi-ai";
2
+ import type { AfterToolCallContext, AfterToolCallResult, AgentContext, AgentEvent, AgentLoopConfig, AgentLoopTurnUpdate, AgentMessage, AgentState, BeforeToolCallContext, BeforeToolCallResult, FinishTurn, PrepareNextTurnContext, PrepareRequest, QueueMode, StreamFn, ToolExecutionMode } from "./types.ts";
3
3
  export type { QueueMode } from "./types.ts";
4
+ /**
5
+ * Initial state for {@link Agent}. Fork: `systemPrompt` and `tools` stay the agent's own state and are folded
6
+ * into the leading system message per request by `buildProviderContext`; they are not copied into `messages`.
7
+ */
8
+ export type AgentInitialState = Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage" | "providerDiagnostic">>;
4
9
  /** Options for constructing an {@link Agent}. */
5
10
  export interface AgentOptions {
6
- initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage" | "providerDiagnostic">>;
11
+ initialState?: AgentInitialState;
7
12
  convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
8
13
  transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
9
14
  streamFn: StreamFn;
10
15
  getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
11
16
  onPayload?: SimpleStreamOptions["onPayload"];
12
17
  onResponse?: SimpleStreamOptions["onResponse"];
18
+ onProviderStreamEvent?: SimpleStreamOptions["onProviderStreamEvent"];
13
19
  beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;
14
20
  afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;
15
- shouldStopAfterTurn?: (context: ShouldStopAfterTurnContext, signal?: AbortSignal) => boolean | Promise<boolean>;
21
+ finishTurn?: FinishTurn;
22
+ prepareRequest?: PrepareRequest;
16
23
  prepareNextTurn?: (signal?: AbortSignal) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
17
24
  prepareNextTurnWithContext?: (context: PrepareNextTurnContext, signal?: AbortSignal) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
18
25
  steeringMode?: QueueMode;
@@ -55,9 +62,11 @@ export declare class Agent {
55
62
  getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
56
63
  onPayload?: SimpleStreamOptions["onPayload"];
57
64
  onResponse?: SimpleStreamOptions["onResponse"];
65
+ onProviderStreamEvent?: SimpleStreamOptions["onProviderStreamEvent"];
58
66
  beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;
59
67
  afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;
60
- shouldStopAfterTurn?: (context: ShouldStopAfterTurnContext, signal?: AbortSignal) => boolean | Promise<boolean>;
68
+ finishTurn?: FinishTurn;
69
+ prepareRequest?: PrepareRequest;
61
70
  prepareNextTurn?: (signal?: AbortSignal) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
62
71
  prepareNextTurnWithContext?: (context: PrepareNextTurnContext, signal?: AbortSignal) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
63
72
  private activeRun?;
@@ -82,7 +91,7 @@ export declare class Agent {
82
91
  abortServerSideFallback?: boolean;
83
92
  /** Cursor exec-channel tool handlers; see {@link AgentLoopConfig.cursorExecHandlers}. */
84
93
  cursorExecHandlers?: AgentLoopConfig["cursorExecHandlers"];
85
- buildProviderContext(context: AgentContext, signal?: AbortSignal): Promise<Context>;
94
+ buildProviderContext(context: AgentContext, signal?: AbortSignal): Promise<TranscriptContext>;
86
95
  constructor(options: AgentOptions);
87
96
  /**
88
97
  * Subscribe to agent lifecycle events.
@@ -119,6 +128,8 @@ export declare class Agent {
119
128
  clearAllQueues(): void;
120
129
  /** Returns true when either queue still contains pending messages. */
121
130
  hasQueuedMessages(): boolean;
131
+ /** Preview the messages selected for the next turn without consuming them. */
132
+ peekQueuedMessages(): AgentMessage[];
122
133
  /** Active abort signal for the current run, if any. */
123
134
  get signal(): AbortSignal | undefined;
124
135
  /** Abort the current run, if one is active. */
@@ -136,7 +147,10 @@ export declare class Agent {
136
147
  * This resolves after `agent_end` listeners settle.
137
148
  */
138
149
  waitForIdle(): Promise<void>;
139
- /** Clear transcript state, runtime state, and queued messages. */
150
+ /**
151
+ * Clear transcript state, runtime state, and queued messages. Fork: the prompt/tool baseline is agent state
152
+ * (`systemPrompt`, `tools`), so transcript system messages are conversation content and are cleared too.
153
+ */
140
154
  reset(): void;
141
155
  /** Start a new prompt from text, a single message, or a batch of messages. */
142
156
  prompt(message: AgentMessage | AgentMessage[]): Promise<void>;