@poncho-ai/harness 0.41.0 → 0.42.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,5 +1,5 @@
1
1
 
2
- > @poncho-ai/harness@0.41.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.42.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
3
3
  > node scripts/embed-docs.js && tsup src/index.ts --format esm --dts
4
4
 
5
5
  [embed-docs] Generated poncho-docs.ts with 4 topics
@@ -8,9 +8,9 @@
8
8
  CLI tsup v8.5.1
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
- ESM dist/index.js 493.42 KB
11
+ ESM dist/index.js 504.78 KB
12
12
  ESM dist/isolate-VY35DGLM.js 49.43 KB
13
- ESM ⚡️ Build success in 238ms
13
+ ESM ⚡️ Build success in 228ms
14
14
  DTS Build start
15
- DTS ⚡️ Build success in 7385ms
16
- DTS dist/index.d.ts 77.00 KB
15
+ DTS ⚡️ Build success in 7856ms
16
+ DTS dist/index.d.ts 79.11 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.42.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`39793b0`](https://github.com/cesr/poncho-ai/commit/39793b0ab11ed26f140af6fc9c0cd3e1b1c83fec) Thanks [@cesr](https://github.com/cesr)! - harness: extract `runConversationTurn` helper; refactor CLI to use it
8
+
9
+ Lifts the inline turn lifecycle from the CLI's
10
+ `POST /api/conversations/:id/messages` handler (~280 lines of orchestration)
11
+ into a new public helper at `@poncho-ai/harness`.
12
+
13
+ The helper handles the full conversation lifecycle for a primary chat
14
+ turn: load the conversation with archive, resolve canonical history,
15
+ upload files via the harness's upload store, build stable user/assistant
16
+ ids, persist the user message immediately, drive `executeConversationTurn`,
17
+ periodically persist the in-flight assistant draft on `step:completed`
18
+ and `tool:approval:required`, persist on `tool:approval:checkpoint` and
19
+ `run:completed` continuation, rebuild history on `compaction:completed`,
20
+ apply turn metadata on success, and persist partial state on
21
+ cancel/error.
22
+
23
+ Caller responsibilities (auth, active-run dedup, streaming, continuation
24
+ HTTP self-fetch, title inference) stay outside the helper — passed in
25
+ via opts or handled around the call. `opts.onEvent` is invoked for every
26
+ `AgentEvent` for downstream forwarding (SSE, WebSocket, telemetry, etc.).
27
+
28
+ The CLI's handler now delegates to `runConversationTurn` (drops from
29
+ ~430 to ~150 lines). Consumers like PonchOS can call the same helper
30
+ to ship the _exact_ same conversation lifecycle without duplicating
31
+ the orchestration.
32
+
33
+ Public API additions:
34
+ - `runConversationTurn(opts): Promise<RunConversationTurnResult>`
35
+ - `RunConversationTurnOpts`
36
+ - `RunConversationTurnResult`
37
+
38
+ No behavior changes. The helper is a verbatim extraction of the CLI's
39
+ prior inline implementation.
40
+
41
+ ### Patch Changes
42
+
43
+ - [`111d24e`](https://github.com/cesr/poncho-ai/commit/111d24efaab054ef7543c396085f8f4d41e7976a) Thanks [@cesr](https://github.com/cesr)! - cli: include VFS skills in the chat input slash command menu
44
+
45
+ The `/api/slash-commands` endpoint was returning only repo-loaded skills,
46
+ so tenant-authored skills stored in the VFS (`/skills/<name>/SKILL.md`)
47
+ never appeared in the `/` autocomplete bar even though the agent could
48
+ already see and run them at conversation time.
49
+
50
+ The endpoint now resolves skills per-tenant via a new
51
+ `harness.listSkillsForTenant(tenantId)` and applies the same repo-wins
52
+ collision semantics used elsewhere in the harness.
53
+
3
54
  ## 0.41.0
4
55
 
5
56
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { LanguageModel } from 'ai';
2
2
  import * as _poncho_ai_sdk from '@poncho-ai/sdk';
3
- import { Message, ToolContext, ToolDefinition, JsonSchema, RunResult, AgentFailure, RunInput, AgentEvent } from '@poncho-ai/sdk';
3
+ import { Message, ToolContext, ToolDefinition, JsonSchema, RunResult, AgentFailure, RunInput, AgentEvent, FileInput } from '@poncho-ai/sdk';
4
4
  export { ToolDefinition, defineTool } from '@poncho-ai/sdk';
5
5
  import { z } from 'zod';
6
6
  import { IFileSystem, BufferEncoding, FsStat, FileContent, MkdirOptions, RmOptions, CpOptions, Bash } from 'just-bash';
@@ -1186,6 +1186,10 @@ declare class AgentHarness {
1186
1186
  name: string;
1187
1187
  description: string;
1188
1188
  }>;
1189
+ listSkillsForTenant(tenantId: string | undefined | null): Promise<Array<{
1190
+ name: string;
1191
+ description: string;
1192
+ }>>;
1189
1193
  /**
1190
1194
  * Wraps the run() generator with an OTel root span (invoke_agent) so all
1191
1195
  * child spans (LLM calls via AI SDK, tool execution) group under one trace.
@@ -1879,4 +1883,46 @@ declare class AgentOrchestrator {
1879
1883
  recoverStaleSubagents(): Promise<void>;
1880
1884
  }
1881
1885
 
1882
- export { type ActiveConversationRun, type ActiveSubagentRun, type AgentFrontmatter, AgentHarness, type AgentIdentity, type AgentLimitsConfig, type AgentModelConfig, AgentOrchestrator, type ApprovalEventItem, type ArchivedToolResult$1 as ArchivedToolResult, type BashConfig, BashEnvironmentManager, type BashExecutionLimits, type BuiltInToolToggles, CALLBACK_LOCK_STALE_MS, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type ContinuationHooks, type Conversation, type ConversationCreateInit, type ConversationState, type ConversationStatusSnapshot, type ConversationStore, type ConversationSummary, type CreateSkillToolsOptions, type CronJobConfig, type EventSink, type ExecuteTurnResult, type HarnessOptions, type HarnessRunOutput, type HistorySource, InMemoryConversationStore, InMemoryEngine, InMemoryStateStore, type IsolateBinding, type IsolateConfig, LocalMcpBridge, LocalUploadStore, MAX_CONCURRENT_SUBAGENTS, MAX_CONTINUATION_COUNT, MAX_SUBAGENT_CALLBACK_COUNT, MAX_SUBAGENT_NESTING, type MainMemory, type McpConfig, type MemoryConfig, type MemoryStore, type MessagingChannelConfig, type ModelProviderFactory, type NetworkConfig, OPENAI_CODEX_CLIENT_ID, type OpenAICodexAuthConfig, type OpenAICodexDeviceAuthRequest, type OpenAICodexSession, type OrchestratorHooks, type OrchestratorOptions, type OtlpConfig, type OtlpOption, PONCHO_UPLOAD_SCHEME, type ParsedAgent, type PendingSubagentApproval, type PendingSubagentResult, type PendingToolCall, type PonchoConfig, PonchoFsAdapter, PostgresEngine, type ProviderConfig, type Recurrence, type RecurrenceType, type Reminder, type ReminderCreateInput, type ReminderStatus, type ReminderStore, type RemoteMcpServerConfig, type RunOutcome, type RunRequest, type RuntimeRenderContext, S3UploadStore, STALE_SUBAGENT_THRESHOLD_MS, STORAGE_SCHEMA_VERSION, type SecretsStore, type SkillContextEntry, type SkillMetadata, type SkillSource, SqliteEngine, type StateConfig, type StateProviderName, type StateStore, type StorageConfig, type StorageEngine, type StorageFactoryOptions, type StorageProvider, type StoredApproval, type SubagentManager, type SubagentResult, type SubagentSpawnResult, type SubagentSummary, TOOL_RESULT_ARCHIVE_PARAM, type TelemetryConfig, TelemetryEmitter, type TenantTokenPayload, type ToolAccess, type ToolCall, ToolDispatcher, type ToolExecutionResult, type TurnDraftState, type TurnResultMetadata, type TurnSection, type UploadStore, type UploadsConfig, VFS_SCHEME, VercelBlobUploadStore, type VfsDirEntry, type VfsStat, applyTurnMetadata, buildAgentDirectoryName, buildApprovalCheckpoints, buildAssistantMetadata, buildSkillContextWindow, buildToolCompletedText, cloneSections, compactMessages, completeOpenAICodexDeviceAuth, computeNextOccurrence, createBashTool, createConversationStore, createConversationStoreFromEngine, createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createMemoryStore, createMemoryStoreFromEngine, createMemoryTools, createModelProvider, createReminderStore, createReminderStoreFromEngine, createReminderTools, createSearchTools, createSecretsStore, createSkillTools, createStateStore, createStorageEngine, createSubagentTools, createTodoStoreFromEngine, createTurnDraftState, createUploadStore, createWriteTool, deleteOpenAICodexSession, deriveUploadKey, ensureAgentIdentity, estimateTokens, estimateTotalTokens, executeConversationTurn, findSafeSplitPoint, flushTurnDraft, generateAgentId, getAgentStoreDirectory, getModelContextWindow, getOpenAICodexAccessToken, getOpenAICodexAuthFilePath, getOpenAICodexRequiredScopes, getPonchoStoreRoot, isMessageArray, jsonSchemaToZod, loadCanonicalHistory, loadPonchoConfig, loadRunHistory, loadSkillContext, loadSkillInstructions, loadSkillMetadata, loadVfsSkillMetadata, mergeSkills, normalizeApprovalCheckpoint, normalizeOtlp, normalizeScriptPolicyPath, parseAgentFile, parseAgentMarkdown, parseSkillFrontmatter, ponchoDocsTool, readOpenAICodexSession, readSkillResource, recordStandardTurnEvent, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveEnv, resolveMemoryConfig, resolveRunRequest, resolveSkillDirs, resolveStateConfig, slugifyStorageComponent, startOpenAICodexDeviceAuth, verifyTenantToken, withToolResultArchiveParam, writeOpenAICodexSession };
1886
+ interface RunConversationTurnOpts {
1887
+ /** Initialised harness instance. */
1888
+ harness: AgentHarness;
1889
+ /** Conversation store backing the turn (typically `engine.conversations` from a StorageEngine). */
1890
+ conversationStore: ConversationStore;
1891
+ conversationId: string;
1892
+ /** The user's new message text. Required (use `""` if you only want to attach files). */
1893
+ task: string;
1894
+ /**
1895
+ * Optional file attachments (FileInput.data is base64 / data URI / https URL).
1896
+ * Files are uploaded via `harness.uploadStore` first so the persisted user
1897
+ * message references stable URLs instead of fat base64 blobs.
1898
+ */
1899
+ files?: FileInput[];
1900
+ /**
1901
+ * Extra parameters merged into runInput.parameters. Use this for recall
1902
+ * corpus, archive lookup keys, messaging metadata, etc. Do NOT include
1903
+ * `__activeConversationId`, `__ownerId`, or the tool-result-archive — the
1904
+ * helper sets those itself.
1905
+ */
1906
+ parameters?: Record<string, unknown>;
1907
+ abortSignal?: AbortSignal;
1908
+ tenantId?: string | null;
1909
+ /** Per-event hook — called for every AgentEvent yielded by the run, in order. */
1910
+ onEvent?: (event: AgentEvent) => void | Promise<void>;
1911
+ }
1912
+ interface RunConversationTurnResult {
1913
+ /** runId of the most recent run started during this turn. */
1914
+ latestRunId: string;
1915
+ /** True if the run was cancelled (via abortSignal or run:cancelled event). */
1916
+ cancelled: boolean;
1917
+ /** True if the run errored. The error has been emitted via onEvent as run:error. */
1918
+ errored: boolean;
1919
+ /** True if the run requested a continuation. Caller is responsible for triggering the continuation. */
1920
+ continuation: boolean;
1921
+ /** True if the run paused at a tool-approval checkpoint. */
1922
+ checkpointed: boolean;
1923
+ contextTokens: number;
1924
+ contextWindow: number;
1925
+ }
1926
+ declare const runConversationTurn: (opts: RunConversationTurnOpts) => Promise<RunConversationTurnResult>;
1927
+
1928
+ export { type ActiveConversationRun, type ActiveSubagentRun, type AgentFrontmatter, AgentHarness, type AgentIdentity, type AgentLimitsConfig, type AgentModelConfig, AgentOrchestrator, type ApprovalEventItem, type ArchivedToolResult$1 as ArchivedToolResult, type BashConfig, BashEnvironmentManager, type BashExecutionLimits, type BuiltInToolToggles, CALLBACK_LOCK_STALE_MS, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type ContinuationHooks, type Conversation, type ConversationCreateInit, type ConversationState, type ConversationStatusSnapshot, type ConversationStore, type ConversationSummary, type CreateSkillToolsOptions, type CronJobConfig, type EventSink, type ExecuteTurnResult, type HarnessOptions, type HarnessRunOutput, type HistorySource, InMemoryConversationStore, InMemoryEngine, InMemoryStateStore, type IsolateBinding, type IsolateConfig, LocalMcpBridge, LocalUploadStore, MAX_CONCURRENT_SUBAGENTS, MAX_CONTINUATION_COUNT, MAX_SUBAGENT_CALLBACK_COUNT, MAX_SUBAGENT_NESTING, type MainMemory, type McpConfig, type MemoryConfig, type MemoryStore, type MessagingChannelConfig, type ModelProviderFactory, type NetworkConfig, OPENAI_CODEX_CLIENT_ID, type OpenAICodexAuthConfig, type OpenAICodexDeviceAuthRequest, type OpenAICodexSession, type OrchestratorHooks, type OrchestratorOptions, type OtlpConfig, type OtlpOption, PONCHO_UPLOAD_SCHEME, type ParsedAgent, type PendingSubagentApproval, type PendingSubagentResult, type PendingToolCall, type PonchoConfig, PonchoFsAdapter, PostgresEngine, type ProviderConfig, type Recurrence, type RecurrenceType, type Reminder, type ReminderCreateInput, type ReminderStatus, type ReminderStore, type RemoteMcpServerConfig, type RunConversationTurnOpts, type RunConversationTurnResult, type RunOutcome, type RunRequest, type RuntimeRenderContext, S3UploadStore, STALE_SUBAGENT_THRESHOLD_MS, STORAGE_SCHEMA_VERSION, type SecretsStore, type SkillContextEntry, type SkillMetadata, type SkillSource, SqliteEngine, type StateConfig, type StateProviderName, type StateStore, type StorageConfig, type StorageEngine, type StorageFactoryOptions, type StorageProvider, type StoredApproval, type SubagentManager, type SubagentResult, type SubagentSpawnResult, type SubagentSummary, TOOL_RESULT_ARCHIVE_PARAM, type TelemetryConfig, TelemetryEmitter, type TenantTokenPayload, type ToolAccess, type ToolCall, ToolDispatcher, type ToolExecutionResult, type TurnDraftState, type TurnResultMetadata, type TurnSection, type UploadStore, type UploadsConfig, VFS_SCHEME, VercelBlobUploadStore, type VfsDirEntry, type VfsStat, applyTurnMetadata, buildAgentDirectoryName, buildApprovalCheckpoints, buildAssistantMetadata, buildSkillContextWindow, buildToolCompletedText, cloneSections, compactMessages, completeOpenAICodexDeviceAuth, computeNextOccurrence, createBashTool, createConversationStore, createConversationStoreFromEngine, createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createMemoryStore, createMemoryStoreFromEngine, createMemoryTools, createModelProvider, createReminderStore, createReminderStoreFromEngine, createReminderTools, createSearchTools, createSecretsStore, createSkillTools, createStateStore, createStorageEngine, createSubagentTools, createTodoStoreFromEngine, createTurnDraftState, createUploadStore, createWriteTool, deleteOpenAICodexSession, deriveUploadKey, ensureAgentIdentity, estimateTokens, estimateTotalTokens, executeConversationTurn, findSafeSplitPoint, flushTurnDraft, generateAgentId, getAgentStoreDirectory, getModelContextWindow, getOpenAICodexAccessToken, getOpenAICodexAuthFilePath, getOpenAICodexRequiredScopes, getPonchoStoreRoot, isMessageArray, jsonSchemaToZod, loadCanonicalHistory, loadPonchoConfig, loadRunHistory, loadSkillContext, loadSkillInstructions, loadSkillMetadata, loadVfsSkillMetadata, mergeSkills, normalizeApprovalCheckpoint, normalizeOtlp, normalizeScriptPolicyPath, parseAgentFile, parseAgentMarkdown, parseSkillFrontmatter, ponchoDocsTool, readOpenAICodexSession, readSkillResource, recordStandardTurnEvent, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveEnv, resolveMemoryConfig, resolveRunRequest, resolveSkillDirs, resolveStateConfig, runConversationTurn, slugifyStorageComponent, startOpenAICodexDeviceAuth, verifyTenantToken, withToolResultArchiveParam, writeOpenAICodexSession };
package/dist/index.js CHANGED
@@ -9654,6 +9654,10 @@ var AgentHarness = class _AgentHarness {
9654
9654
  listSkills() {
9655
9655
  return this.loadedSkills.map((s) => ({ name: s.name, description: s.description }));
9656
9656
  }
9657
+ async listSkillsForTenant(tenantId) {
9658
+ const skills = await this.getSkillsForTenant(tenantId);
9659
+ return skills.map((s) => ({ name: s.name, description: s.description }));
9660
+ }
9657
9661
  /**
9658
9662
  * Wraps the run() generator with an OTel root span (invoke_agent) so all
9659
9663
  * child spans (LLM calls via AI SDK, tool execution) group under one trace.
@@ -12991,6 +12995,311 @@ ${resultBody}`,
12991
12995
  }
12992
12996
  };
12993
12997
 
12998
+ // src/orchestrator/run-conversation-turn.ts
12999
+ import { randomUUID as randomUUID6 } from "crypto";
13000
+ import { createLogger as createLogger7 } from "@poncho-ai/sdk";
13001
+ var log = createLogger7("orchestrator");
13002
+ var runConversationTurn = async (opts) => {
13003
+ const conversation = await opts.conversationStore.getWithArchive(opts.conversationId);
13004
+ if (!conversation) {
13005
+ throw new Error(`Conversation not found: ${opts.conversationId}`);
13006
+ }
13007
+ const canonicalHistory = resolveRunRequest(conversation, {
13008
+ conversationId: opts.conversationId,
13009
+ messages: conversation.messages
13010
+ });
13011
+ const shouldRebuildCanonical = canonicalHistory.shouldRebuildCanonical;
13012
+ const harnessMessages = [...canonicalHistory.messages];
13013
+ const historyMessages = [...conversation.messages];
13014
+ const preRunMessages = [...conversation.messages];
13015
+ let userContent = opts.task;
13016
+ if (opts.files && opts.files.length > 0 && opts.harness.uploadStore) {
13017
+ const uploadedParts = await Promise.all(
13018
+ opts.files.map(async (f) => {
13019
+ const buf = Buffer.from(f.data, "base64");
13020
+ const key = deriveUploadKey(buf, f.mediaType);
13021
+ const ref = await opts.harness.uploadStore.put(key, buf, f.mediaType);
13022
+ return {
13023
+ type: "file",
13024
+ data: ref,
13025
+ mediaType: f.mediaType,
13026
+ filename: f.filename
13027
+ };
13028
+ })
13029
+ );
13030
+ userContent = [
13031
+ { type: "text", text: opts.task },
13032
+ ...uploadedParts
13033
+ ];
13034
+ }
13035
+ const turnTimestamp = Date.now();
13036
+ const userMessage = {
13037
+ role: "user",
13038
+ content: userContent,
13039
+ metadata: { id: randomUUID6(), timestamp: turnTimestamp }
13040
+ };
13041
+ const assistantId = randomUUID6();
13042
+ const draft = createTurnDraftState();
13043
+ let latestRunId = conversation.runtimeRunId ?? "";
13044
+ let runCancelled = false;
13045
+ let runContinuationMessages;
13046
+ let cancelHarnessMessages;
13047
+ let checkpointedRun = false;
13048
+ const buildMessages = () => {
13049
+ const draftSections = cloneSections(draft.sections);
13050
+ if (draft.currentTools.length > 0) {
13051
+ draftSections.push({ type: "tools", content: [...draft.currentTools] });
13052
+ }
13053
+ if (draft.currentText.length > 0) {
13054
+ draftSections.push({ type: "text", content: draft.currentText });
13055
+ }
13056
+ const userTurn = [userMessage];
13057
+ const hasDraftContent = draft.assistantResponse.length > 0 || draft.toolTimeline.length > 0 || draftSections.length > 0;
13058
+ if (!hasDraftContent) {
13059
+ return [...historyMessages, ...userTurn];
13060
+ }
13061
+ return [
13062
+ ...historyMessages,
13063
+ ...userTurn,
13064
+ {
13065
+ role: "assistant",
13066
+ content: draft.assistantResponse,
13067
+ metadata: buildAssistantMetadata(draft, draftSections, {
13068
+ id: assistantId,
13069
+ timestamp: turnTimestamp
13070
+ })
13071
+ }
13072
+ ];
13073
+ };
13074
+ const persistDraft = async () => {
13075
+ if (draft.assistantResponse.length === 0 && draft.toolTimeline.length === 0) return;
13076
+ conversation.messages = buildMessages();
13077
+ conversation.updatedAt = Date.now();
13078
+ await opts.conversationStore.update(conversation);
13079
+ };
13080
+ conversation.messages = [...historyMessages, userMessage];
13081
+ conversation.subagentCallbackCount = 0;
13082
+ conversation._continuationCount = void 0;
13083
+ conversation.updatedAt = Date.now();
13084
+ opts.conversationStore.update(conversation).catch((err) => {
13085
+ log.error(
13086
+ `failed to persist user turn: ${err instanceof Error ? err.message : String(err)}`
13087
+ );
13088
+ });
13089
+ try {
13090
+ const execution = await executeConversationTurn({
13091
+ harness: opts.harness,
13092
+ runInput: {
13093
+ task: opts.task,
13094
+ conversationId: opts.conversationId,
13095
+ tenantId: opts.tenantId ?? void 0,
13096
+ parameters: withToolResultArchiveParam(
13097
+ {
13098
+ ...opts.parameters ?? {},
13099
+ __activeConversationId: opts.conversationId,
13100
+ __ownerId: conversation.ownerId
13101
+ },
13102
+ conversation
13103
+ ),
13104
+ messages: harnessMessages,
13105
+ files: opts.files && opts.files.length > 0 ? opts.files : void 0,
13106
+ abortSignal: opts.abortSignal
13107
+ },
13108
+ initialContextTokens: conversation.contextTokens ?? 0,
13109
+ initialContextWindow: conversation.contextWindow ?? 0,
13110
+ onEvent: async (event, eventDraft) => {
13111
+ draft.assistantResponse = eventDraft.assistantResponse;
13112
+ draft.toolTimeline = eventDraft.toolTimeline;
13113
+ draft.sections = eventDraft.sections;
13114
+ draft.currentTools = eventDraft.currentTools;
13115
+ draft.currentText = eventDraft.currentText;
13116
+ if (event.type === "run:started") {
13117
+ latestRunId = event.runId;
13118
+ }
13119
+ if (event.type === "run:cancelled") {
13120
+ runCancelled = true;
13121
+ if (event.messages) cancelHarnessMessages = event.messages;
13122
+ }
13123
+ if (event.type === "compaction:completed") {
13124
+ if (event.compactedMessages) {
13125
+ historyMessages.length = 0;
13126
+ historyMessages.push(...event.compactedMessages);
13127
+ const preservedFromHistory = historyMessages.length - 1;
13128
+ const removedCount = preRunMessages.length - Math.max(0, preservedFromHistory);
13129
+ const existingHistory = conversation.compactedHistory ?? [];
13130
+ conversation.compactedHistory = [
13131
+ ...existingHistory,
13132
+ ...preRunMessages.slice(0, removedCount)
13133
+ ];
13134
+ }
13135
+ }
13136
+ if (event.type === "step:completed") {
13137
+ await persistDraft();
13138
+ }
13139
+ if (event.type === "tool:approval:required") {
13140
+ const toolText = `- approval required \`${event.tool}\``;
13141
+ draft.toolTimeline.push(toolText);
13142
+ draft.currentTools.push(toolText);
13143
+ const existing = Array.isArray(conversation.pendingApprovals) ? conversation.pendingApprovals : [];
13144
+ if (!existing.some((a) => a.approvalId === event.approvalId)) {
13145
+ conversation.pendingApprovals = [
13146
+ ...existing,
13147
+ {
13148
+ approvalId: event.approvalId,
13149
+ runId: latestRunId || conversation.runtimeRunId || "",
13150
+ tool: event.tool,
13151
+ toolCallId: void 0,
13152
+ input: event.input ?? {},
13153
+ checkpointMessages: void 0,
13154
+ baseMessageCount: historyMessages.length,
13155
+ pendingToolCalls: []
13156
+ }
13157
+ ];
13158
+ conversation.updatedAt = Date.now();
13159
+ await opts.conversationStore.update(conversation);
13160
+ }
13161
+ await persistDraft();
13162
+ }
13163
+ if (event.type === "tool:approval:checkpoint") {
13164
+ conversation.messages = buildMessages();
13165
+ conversation.pendingApprovals = buildApprovalCheckpoints({
13166
+ approvals: event.approvals,
13167
+ runId: latestRunId,
13168
+ checkpointMessages: event.checkpointMessages,
13169
+ baseMessageCount: historyMessages.length,
13170
+ pendingToolCalls: event.pendingToolCalls
13171
+ });
13172
+ conversation._toolResultArchive = opts.harness.getToolResultArchive(
13173
+ opts.conversationId
13174
+ );
13175
+ conversation.updatedAt = Date.now();
13176
+ await opts.conversationStore.update(conversation);
13177
+ checkpointedRun = true;
13178
+ }
13179
+ if (event.type === "run:completed") {
13180
+ if (event.result.continuation && event.result.continuationMessages) {
13181
+ runContinuationMessages = event.result.continuationMessages;
13182
+ conversation.messages = buildMessages();
13183
+ conversation._continuationMessages = runContinuationMessages;
13184
+ conversation._harnessMessages = runContinuationMessages;
13185
+ conversation._toolResultArchive = opts.harness.getToolResultArchive(
13186
+ opts.conversationId
13187
+ );
13188
+ conversation.runtimeRunId = latestRunId || conversation.runtimeRunId;
13189
+ if (!checkpointedRun) {
13190
+ conversation.pendingApprovals = [];
13191
+ }
13192
+ if ((event.result.contextTokens ?? 0) > 0) {
13193
+ conversation.contextTokens = event.result.contextTokens;
13194
+ }
13195
+ if ((event.result.contextWindow ?? 0) > 0) {
13196
+ conversation.contextWindow = event.result.contextWindow;
13197
+ }
13198
+ conversation.updatedAt = Date.now();
13199
+ await opts.conversationStore.update(conversation);
13200
+ }
13201
+ }
13202
+ if (opts.onEvent) {
13203
+ await opts.onEvent(event);
13204
+ }
13205
+ }
13206
+ });
13207
+ flushTurnDraft(draft);
13208
+ latestRunId = execution.latestRunId || latestRunId;
13209
+ if (!checkpointedRun && !runContinuationMessages) {
13210
+ conversation.messages = buildMessages();
13211
+ applyTurnMetadata(
13212
+ conversation,
13213
+ {
13214
+ latestRunId,
13215
+ contextTokens: execution.runContextTokens,
13216
+ contextWindow: execution.runContextWindow,
13217
+ harnessMessages: execution.runHarnessMessages,
13218
+ toolResultArchive: opts.harness.getToolResultArchive(opts.conversationId)
13219
+ },
13220
+ { shouldRebuildCanonical }
13221
+ );
13222
+ await opts.conversationStore.update(conversation);
13223
+ }
13224
+ return {
13225
+ latestRunId,
13226
+ cancelled: runCancelled,
13227
+ errored: false,
13228
+ continuation: !!runContinuationMessages,
13229
+ checkpointed: checkpointedRun,
13230
+ contextTokens: execution.runContextTokens,
13231
+ contextWindow: execution.runContextWindow
13232
+ };
13233
+ } catch (error) {
13234
+ flushTurnDraft(draft);
13235
+ const aborted = opts.abortSignal?.aborted === true;
13236
+ if (aborted || runCancelled) {
13237
+ if (draft.assistantResponse.length > 0 || draft.toolTimeline.length > 0 || draft.sections.length > 0) {
13238
+ conversation.messages = buildMessages();
13239
+ applyTurnMetadata(
13240
+ conversation,
13241
+ {
13242
+ latestRunId,
13243
+ contextTokens: 0,
13244
+ contextWindow: 0,
13245
+ harnessMessages: cancelHarnessMessages,
13246
+ toolResultArchive: opts.harness.getToolResultArchive(opts.conversationId)
13247
+ },
13248
+ { shouldRebuildCanonical: true }
13249
+ );
13250
+ await opts.conversationStore.update(conversation);
13251
+ }
13252
+ if (!checkpointedRun) {
13253
+ const fresh = await opts.conversationStore.get(opts.conversationId);
13254
+ if (fresh && Array.isArray(fresh.pendingApprovals) && fresh.pendingApprovals.length > 0) {
13255
+ fresh.pendingApprovals = [];
13256
+ await opts.conversationStore.update(fresh);
13257
+ }
13258
+ }
13259
+ return {
13260
+ latestRunId,
13261
+ cancelled: true,
13262
+ errored: false,
13263
+ continuation: false,
13264
+ checkpointed: checkpointedRun,
13265
+ contextTokens: 0,
13266
+ contextWindow: 0
13267
+ };
13268
+ }
13269
+ const errorEvent = {
13270
+ type: "run:error",
13271
+ runId: latestRunId || "run_unknown",
13272
+ error: {
13273
+ code: "RUN_ERROR",
13274
+ message: error instanceof Error ? error.message : "Unknown error"
13275
+ }
13276
+ };
13277
+ if (opts.onEvent) {
13278
+ try {
13279
+ await opts.onEvent(errorEvent);
13280
+ } catch (hookErr) {
13281
+ log.error(
13282
+ `onEvent threw on run:error: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`
13283
+ );
13284
+ }
13285
+ }
13286
+ if (draft.assistantResponse.length > 0 || draft.toolTimeline.length > 0 || draft.sections.length > 0) {
13287
+ conversation.messages = buildMessages();
13288
+ conversation.updatedAt = Date.now();
13289
+ await opts.conversationStore.update(conversation);
13290
+ }
13291
+ return {
13292
+ latestRunId,
13293
+ cancelled: false,
13294
+ errored: true,
13295
+ continuation: false,
13296
+ checkpointed: checkpointedRun,
13297
+ contextTokens: 0,
13298
+ contextWindow: 0
13299
+ };
13300
+ }
13301
+ };
13302
+
12994
13303
  // src/index.ts
12995
13304
  import { defineTool as defineTool13 } from "@poncho-ai/sdk";
12996
13305
  export {
@@ -13098,6 +13407,7 @@ export {
13098
13407
  resolveRunRequest,
13099
13408
  resolveSkillDirs,
13100
13409
  resolveStateConfig,
13410
+ runConversationTurn,
13101
13411
  slugifyStorageComponent,
13102
13412
  startOpenAICodexDeviceAuth,
13103
13413
  verifyTenantToken,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/harness.ts CHANGED
@@ -1940,6 +1940,13 @@ export class AgentHarness {
1940
1940
  return this.loadedSkills.map((s) => ({ name: s.name, description: s.description }));
1941
1941
  }
1942
1942
 
1943
+ async listSkillsForTenant(
1944
+ tenantId: string | undefined | null,
1945
+ ): Promise<Array<{ name: string; description: string }>> {
1946
+ const skills = await this.getSkillsForTenant(tenantId);
1947
+ return skills.map((s) => ({ name: s.name, description: s.description }));
1948
+ }
1949
+
1943
1950
  /**
1944
1951
  * Wraps the run() generator with an OTel root span (invoke_agent) so all
1945
1952
  * child spans (LLM calls via AI SDK, tool execution) group under one trace.
@@ -52,3 +52,9 @@ export {
52
52
  type ContinuationHooks,
53
53
  type OrchestratorOptions,
54
54
  } from "./orchestrator.js";
55
+
56
+ export {
57
+ runConversationTurn,
58
+ type RunConversationTurnOpts,
59
+ type RunConversationTurnResult,
60
+ } from "./run-conversation-turn.js";
@@ -0,0 +1,420 @@
1
+ // ---------------------------------------------------------------------------
2
+ // runConversationTurn — load-bearing helper that runs a single primary chat
3
+ // turn end-to-end against a ConversationStore: loads the conversation,
4
+ // persists the user message before the run, drives the model + tool loop
5
+ // via executeConversationTurn, periodically persists the in-flight assistant
6
+ // draft, handles approval checkpoints + continuations + cancellation, and
7
+ // finalises the conversation row on completion.
8
+ //
9
+ // This was extracted from packages/cli/src/index.ts (POST
10
+ // /api/conversations/:id/messages handler) so consumers other than the CLI
11
+ // (PonchOS, custom servers) can ship the *same* conversation lifecycle
12
+ // without copy-pasting hundreds of lines.
13
+ //
14
+ // Caller responsibilities (NOT done here):
15
+ // - auth / ownership checks
16
+ // - active-run dedup (one run at a time per conversation)
17
+ // - streaming events to a real client (use opts.onEvent)
18
+ // - triggering continuation runs after this returns continuation: true
19
+ // - conversation title inference (helper preserves existing title)
20
+ // ---------------------------------------------------------------------------
21
+
22
+ import { randomUUID } from "node:crypto";
23
+ import type { AgentEvent, FileInput, Message } from "@poncho-ai/sdk";
24
+ import { createLogger } from "@poncho-ai/sdk";
25
+ import type { AgentHarness } from "../harness.js";
26
+ import type { ConversationStore } from "../state.js";
27
+ import { deriveUploadKey } from "../upload-store.js";
28
+ import { withToolResultArchiveParam } from "./continuation.js";
29
+ import { resolveRunRequest } from "./history.js";
30
+ import {
31
+ applyTurnMetadata,
32
+ buildApprovalCheckpoints,
33
+ buildAssistantMetadata,
34
+ cloneSections,
35
+ createTurnDraftState,
36
+ executeConversationTurn,
37
+ flushTurnDraft,
38
+ } from "./turn.js";
39
+
40
+ const log = createLogger("orchestrator");
41
+
42
+ export interface RunConversationTurnOpts {
43
+ /** Initialised harness instance. */
44
+ harness: AgentHarness;
45
+ /** Conversation store backing the turn (typically `engine.conversations` from a StorageEngine). */
46
+ conversationStore: ConversationStore;
47
+ conversationId: string;
48
+ /** The user's new message text. Required (use `""` if you only want to attach files). */
49
+ task: string;
50
+ /**
51
+ * Optional file attachments (FileInput.data is base64 / data URI / https URL).
52
+ * Files are uploaded via `harness.uploadStore` first so the persisted user
53
+ * message references stable URLs instead of fat base64 blobs.
54
+ */
55
+ files?: FileInput[];
56
+ /**
57
+ * Extra parameters merged into runInput.parameters. Use this for recall
58
+ * corpus, archive lookup keys, messaging metadata, etc. Do NOT include
59
+ * `__activeConversationId`, `__ownerId`, or the tool-result-archive — the
60
+ * helper sets those itself.
61
+ */
62
+ parameters?: Record<string, unknown>;
63
+ abortSignal?: AbortSignal;
64
+ tenantId?: string | null;
65
+ /** Per-event hook — called for every AgentEvent yielded by the run, in order. */
66
+ onEvent?: (event: AgentEvent) => void | Promise<void>;
67
+ }
68
+
69
+ export interface RunConversationTurnResult {
70
+ /** runId of the most recent run started during this turn. */
71
+ latestRunId: string;
72
+ /** True if the run was cancelled (via abortSignal or run:cancelled event). */
73
+ cancelled: boolean;
74
+ /** True if the run errored. The error has been emitted via onEvent as run:error. */
75
+ errored: boolean;
76
+ /** True if the run requested a continuation. Caller is responsible for triggering the continuation. */
77
+ continuation: boolean;
78
+ /** True if the run paused at a tool-approval checkpoint. */
79
+ checkpointed: boolean;
80
+ contextTokens: number;
81
+ contextWindow: number;
82
+ }
83
+
84
+ export const runConversationTurn = async (
85
+ opts: RunConversationTurnOpts,
86
+ ): Promise<RunConversationTurnResult> => {
87
+ const conversation = await opts.conversationStore.getWithArchive(opts.conversationId);
88
+ if (!conversation) {
89
+ throw new Error(`Conversation not found: ${opts.conversationId}`);
90
+ }
91
+
92
+ const canonicalHistory = resolveRunRequest(conversation, {
93
+ conversationId: opts.conversationId,
94
+ messages: conversation.messages,
95
+ });
96
+ const shouldRebuildCanonical = canonicalHistory.shouldRebuildCanonical;
97
+ const harnessMessages = [...canonicalHistory.messages];
98
+ const historyMessages = [...conversation.messages];
99
+ const preRunMessages = [...conversation.messages];
100
+
101
+ // Build user content — upload any files first so the persisted message
102
+ // carries stable refs instead of fat base64 blobs.
103
+ let userContent: Message["content"] = opts.task;
104
+ if (opts.files && opts.files.length > 0 && opts.harness.uploadStore) {
105
+ const uploadedParts = await Promise.all(
106
+ opts.files.map(async (f) => {
107
+ const buf = Buffer.from(f.data, "base64");
108
+ const key = deriveUploadKey(buf, f.mediaType);
109
+ const ref = await opts.harness.uploadStore!.put(key, buf, f.mediaType);
110
+ return {
111
+ type: "file" as const,
112
+ data: ref,
113
+ mediaType: f.mediaType,
114
+ filename: f.filename,
115
+ };
116
+ }),
117
+ );
118
+ userContent = [
119
+ { type: "text" as const, text: opts.task },
120
+ ...uploadedParts,
121
+ ];
122
+ }
123
+
124
+ const turnTimestamp = Date.now();
125
+ const userMessage: Message = {
126
+ role: "user",
127
+ content: userContent,
128
+ metadata: { id: randomUUID(), timestamp: turnTimestamp },
129
+ };
130
+ const assistantId = randomUUID();
131
+ const draft = createTurnDraftState();
132
+
133
+ let latestRunId = conversation.runtimeRunId ?? "";
134
+ let runCancelled = false;
135
+ let runContinuationMessages: Message[] | undefined;
136
+ let cancelHarnessMessages: Message[] | undefined;
137
+ let checkpointedRun = false;
138
+
139
+ const buildMessages = (): Message[] => {
140
+ const draftSections = cloneSections(draft.sections);
141
+ if (draft.currentTools.length > 0) {
142
+ draftSections.push({ type: "tools", content: [...draft.currentTools] });
143
+ }
144
+ if (draft.currentText.length > 0) {
145
+ draftSections.push({ type: "text", content: draft.currentText });
146
+ }
147
+ const userTurn: Message[] = [userMessage];
148
+ const hasDraftContent =
149
+ draft.assistantResponse.length > 0 ||
150
+ draft.toolTimeline.length > 0 ||
151
+ draftSections.length > 0;
152
+ if (!hasDraftContent) {
153
+ return [...historyMessages, ...userTurn];
154
+ }
155
+ return [
156
+ ...historyMessages,
157
+ ...userTurn,
158
+ {
159
+ role: "assistant" as const,
160
+ content: draft.assistantResponse,
161
+ metadata: buildAssistantMetadata(draft, draftSections, {
162
+ id: assistantId,
163
+ timestamp: turnTimestamp,
164
+ }),
165
+ },
166
+ ];
167
+ };
168
+
169
+ const persistDraft = async (): Promise<void> => {
170
+ if (draft.assistantResponse.length === 0 && draft.toolTimeline.length === 0) return;
171
+ conversation.messages = buildMessages();
172
+ conversation.updatedAt = Date.now();
173
+ await opts.conversationStore.update(conversation);
174
+ };
175
+
176
+ // Persist the user turn immediately so a crash mid-run still records what
177
+ // the user said. Fire-and-forget — don't block the run.
178
+ conversation.messages = [...historyMessages, userMessage];
179
+ conversation.subagentCallbackCount = 0;
180
+ conversation._continuationCount = undefined;
181
+ conversation.updatedAt = Date.now();
182
+ opts.conversationStore.update(conversation).catch((err) => {
183
+ log.error(
184
+ `failed to persist user turn: ${err instanceof Error ? err.message : String(err)}`,
185
+ );
186
+ });
187
+
188
+ try {
189
+ const execution = await executeConversationTurn({
190
+ harness: opts.harness,
191
+ runInput: {
192
+ task: opts.task,
193
+ conversationId: opts.conversationId,
194
+ tenantId: opts.tenantId ?? undefined,
195
+ parameters: withToolResultArchiveParam(
196
+ {
197
+ ...(opts.parameters ?? {}),
198
+ __activeConversationId: opts.conversationId,
199
+ __ownerId: conversation.ownerId,
200
+ },
201
+ conversation,
202
+ ),
203
+ messages: harnessMessages,
204
+ files: opts.files && opts.files.length > 0 ? opts.files : undefined,
205
+ abortSignal: opts.abortSignal,
206
+ },
207
+ initialContextTokens: conversation.contextTokens ?? 0,
208
+ initialContextWindow: conversation.contextWindow ?? 0,
209
+ onEvent: async (event, eventDraft) => {
210
+ // Sync our outer draft from the executor's so persistDraft sees the latest state.
211
+ draft.assistantResponse = eventDraft.assistantResponse;
212
+ draft.toolTimeline = eventDraft.toolTimeline;
213
+ draft.sections = eventDraft.sections;
214
+ draft.currentTools = eventDraft.currentTools;
215
+ draft.currentText = eventDraft.currentText;
216
+
217
+ if (event.type === "run:started") {
218
+ latestRunId = event.runId;
219
+ }
220
+ if (event.type === "run:cancelled") {
221
+ runCancelled = true;
222
+ if (event.messages) cancelHarnessMessages = event.messages;
223
+ }
224
+ if (event.type === "compaction:completed") {
225
+ if (event.compactedMessages) {
226
+ historyMessages.length = 0;
227
+ historyMessages.push(...event.compactedMessages);
228
+ const preservedFromHistory = historyMessages.length - 1;
229
+ const removedCount =
230
+ preRunMessages.length - Math.max(0, preservedFromHistory);
231
+ const existingHistory = conversation.compactedHistory ?? [];
232
+ conversation.compactedHistory = [
233
+ ...existingHistory,
234
+ ...preRunMessages.slice(0, removedCount),
235
+ ];
236
+ }
237
+ }
238
+ if (event.type === "step:completed") {
239
+ await persistDraft();
240
+ }
241
+ if (event.type === "tool:approval:required") {
242
+ const toolText = `- approval required \`${event.tool}\``;
243
+ draft.toolTimeline.push(toolText);
244
+ draft.currentTools.push(toolText);
245
+ const existing = Array.isArray(conversation.pendingApprovals)
246
+ ? conversation.pendingApprovals
247
+ : [];
248
+ if (!existing.some((a) => a.approvalId === event.approvalId)) {
249
+ conversation.pendingApprovals = [
250
+ ...existing,
251
+ {
252
+ approvalId: event.approvalId,
253
+ runId: latestRunId || conversation.runtimeRunId || "",
254
+ tool: event.tool,
255
+ toolCallId: undefined,
256
+ input: (event.input ?? {}) as Record<string, unknown>,
257
+ checkpointMessages: undefined,
258
+ baseMessageCount: historyMessages.length,
259
+ pendingToolCalls: [],
260
+ },
261
+ ];
262
+ conversation.updatedAt = Date.now();
263
+ await opts.conversationStore.update(conversation);
264
+ }
265
+ await persistDraft();
266
+ }
267
+ if (event.type === "tool:approval:checkpoint") {
268
+ conversation.messages = buildMessages();
269
+ conversation.pendingApprovals = buildApprovalCheckpoints({
270
+ approvals: event.approvals,
271
+ runId: latestRunId,
272
+ checkpointMessages: event.checkpointMessages,
273
+ baseMessageCount: historyMessages.length,
274
+ pendingToolCalls: event.pendingToolCalls,
275
+ });
276
+ conversation._toolResultArchive = opts.harness.getToolResultArchive(
277
+ opts.conversationId,
278
+ );
279
+ conversation.updatedAt = Date.now();
280
+ await opts.conversationStore.update(conversation);
281
+ checkpointedRun = true;
282
+ }
283
+ if (event.type === "run:completed") {
284
+ if (event.result.continuation && event.result.continuationMessages) {
285
+ runContinuationMessages = event.result.continuationMessages;
286
+ conversation.messages = buildMessages();
287
+ conversation._continuationMessages = runContinuationMessages;
288
+ conversation._harnessMessages = runContinuationMessages;
289
+ conversation._toolResultArchive = opts.harness.getToolResultArchive(
290
+ opts.conversationId,
291
+ );
292
+ conversation.runtimeRunId = latestRunId || conversation.runtimeRunId;
293
+ if (!checkpointedRun) {
294
+ conversation.pendingApprovals = [];
295
+ }
296
+ if ((event.result.contextTokens ?? 0) > 0) {
297
+ conversation.contextTokens = event.result.contextTokens!;
298
+ }
299
+ if ((event.result.contextWindow ?? 0) > 0) {
300
+ conversation.contextWindow = event.result.contextWindow!;
301
+ }
302
+ conversation.updatedAt = Date.now();
303
+ await opts.conversationStore.update(conversation);
304
+ }
305
+ }
306
+
307
+ if (opts.onEvent) {
308
+ await opts.onEvent(event);
309
+ }
310
+ },
311
+ });
312
+
313
+ flushTurnDraft(draft);
314
+ latestRunId = execution.latestRunId || latestRunId;
315
+
316
+ if (!checkpointedRun && !runContinuationMessages) {
317
+ conversation.messages = buildMessages();
318
+ applyTurnMetadata(
319
+ conversation,
320
+ {
321
+ latestRunId,
322
+ contextTokens: execution.runContextTokens,
323
+ contextWindow: execution.runContextWindow,
324
+ harnessMessages: execution.runHarnessMessages,
325
+ toolResultArchive: opts.harness.getToolResultArchive(opts.conversationId),
326
+ },
327
+ { shouldRebuildCanonical },
328
+ );
329
+ await opts.conversationStore.update(conversation);
330
+ }
331
+
332
+ return {
333
+ latestRunId,
334
+ cancelled: runCancelled,
335
+ errored: false,
336
+ continuation: !!runContinuationMessages,
337
+ checkpointed: checkpointedRun,
338
+ contextTokens: execution.runContextTokens,
339
+ contextWindow: execution.runContextWindow,
340
+ };
341
+ } catch (error) {
342
+ flushTurnDraft(draft);
343
+ const aborted = opts.abortSignal?.aborted === true;
344
+ if (aborted || runCancelled) {
345
+ if (
346
+ draft.assistantResponse.length > 0 ||
347
+ draft.toolTimeline.length > 0 ||
348
+ draft.sections.length > 0
349
+ ) {
350
+ conversation.messages = buildMessages();
351
+ applyTurnMetadata(
352
+ conversation,
353
+ {
354
+ latestRunId,
355
+ contextTokens: 0,
356
+ contextWindow: 0,
357
+ harnessMessages: cancelHarnessMessages,
358
+ toolResultArchive: opts.harness.getToolResultArchive(opts.conversationId),
359
+ },
360
+ { shouldRebuildCanonical: true },
361
+ );
362
+ await opts.conversationStore.update(conversation);
363
+ }
364
+ if (!checkpointedRun) {
365
+ // Clear any pending approvals — the run was cancelled, they're stale.
366
+ const fresh = await opts.conversationStore.get(opts.conversationId);
367
+ if (fresh && Array.isArray(fresh.pendingApprovals) && fresh.pendingApprovals.length > 0) {
368
+ fresh.pendingApprovals = [];
369
+ await opts.conversationStore.update(fresh);
370
+ }
371
+ }
372
+ return {
373
+ latestRunId,
374
+ cancelled: true,
375
+ errored: false,
376
+ continuation: false,
377
+ checkpointed: checkpointedRun,
378
+ contextTokens: 0,
379
+ contextWindow: 0,
380
+ };
381
+ }
382
+
383
+ // Real error: emit run:error, persist whatever we have.
384
+ const errorEvent: AgentEvent = {
385
+ type: "run:error",
386
+ runId: latestRunId || "run_unknown",
387
+ error: {
388
+ code: "RUN_ERROR",
389
+ message: error instanceof Error ? error.message : "Unknown error",
390
+ },
391
+ };
392
+ if (opts.onEvent) {
393
+ try {
394
+ await opts.onEvent(errorEvent);
395
+ } catch (hookErr) {
396
+ log.error(
397
+ `onEvent threw on run:error: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
398
+ );
399
+ }
400
+ }
401
+ if (
402
+ draft.assistantResponse.length > 0 ||
403
+ draft.toolTimeline.length > 0 ||
404
+ draft.sections.length > 0
405
+ ) {
406
+ conversation.messages = buildMessages();
407
+ conversation.updatedAt = Date.now();
408
+ await opts.conversationStore.update(conversation);
409
+ }
410
+ return {
411
+ latestRunId,
412
+ cancelled: false,
413
+ errored: true,
414
+ continuation: false,
415
+ checkpointed: checkpointedRun,
416
+ contextTokens: 0,
417
+ contextWindow: 0,
418
+ };
419
+ }
420
+ };