@poncho-ai/harness 0.61.0 → 0.61.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.
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +32 -0
- package/dist/index.d.ts +35 -1
- package/dist/index.js +104 -41
- package/package.json +1 -1
- package/src/harness.ts +15 -0
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/orchestrator.ts +30 -37
- package/src/orchestrator/turn.ts +130 -0
- package/test/resume-transcript-integrity.test.ts +156 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @poncho-ai/harness@0.61.
|
|
2
|
+
> @poncho-ai/harness@0.61.2 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
|
[34mCLI[39m tsup v8.5.1
|
|
9
9
|
[34mCLI[39m Target: es2022
|
|
10
10
|
[34mESM[39m Build start
|
|
11
|
+
[32mESM[39m [1mdist/index.js [22m[32m581.91 KB[39m
|
|
11
12
|
[32mESM[39m [1mdist/isolate-F2PPSUL6.js [22m[32m53.82 KB[39m
|
|
12
|
-
[32mESM[39m
|
|
13
|
-
[32mESM[39m ⚡️ Build success in 267ms
|
|
13
|
+
[32mESM[39m ⚡️ Build success in 239ms
|
|
14
14
|
[34mDTS[39m Build start
|
|
15
|
-
[32mDTS[39m ⚡️ Build success in
|
|
16
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[
|
|
15
|
+
[32mDTS[39m ⚡️ Build success in 7703ms
|
|
16
|
+
[32mDTS[39m [1mdist/index.d.ts [22m[32m110.56 KB[39m
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# @poncho-ai/harness
|
|
2
2
|
|
|
3
|
+
## 0.61.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`c881cd2`](https://github.com/cesr/poncho-ai/commit/c881cd2cafd4cb70f9614601dd233aafac334b8c) Thanks [@cesr](https://github.com/cesr)! - Telemetry: make context compaction observable and tag subagent runs.
|
|
8
|
+
- When auto-compaction fires mid-run, stamp the active `invoke_agent` span with
|
|
9
|
+
`poncho.compaction.occurred` / `tokens_before` / `tokens_after` /
|
|
10
|
+
`context_window` / `trigger`. Compaction rewrites the message prefix — which
|
|
11
|
+
wipes the per-model prompt cache and forces the next turn to re-create a large
|
|
12
|
+
cached span cold — so "which turns compacted, and what did that cost" was
|
|
13
|
+
previously invisible at the span level. No-op when telemetry is suppressed
|
|
14
|
+
(no active span).
|
|
15
|
+
- Subagent runs spawned by the orchestrator now carry
|
|
16
|
+
`telemetryAttributes` (`poncho.run.kind: "subagent"`, `latitude.tags`,
|
|
17
|
+
and a `latitude.metadata` link to the parent conversation) so subagent spend
|
|
18
|
+
segments out of the parent chat/job on the trace instead of being
|
|
19
|
+
indistinguishable from it.
|
|
20
|
+
|
|
21
|
+
## 0.61.1
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- [`a16f4fa`](https://github.com/cesr/poncho-ai/commit/a16f4fa32f0809d6ff73f0041cb0e0a3fb980bb1) Thanks [@cesr](https://github.com/cesr)! - Fix: checkpoint-resume no longer drops a turn's user message from the model
|
|
26
|
+
transcript. Extract the checkpoint→transcript assembly the orchestrator's
|
|
27
|
+
`resumeRunFromCheckpoint` used into shared, exported helpers —
|
|
28
|
+
`assembleCheckpointMessages`, `buildToolResultMessage`, `buildResumeCheckpoints`
|
|
29
|
+
— so external embedders (which execute gated tools themselves) reconstruct the
|
|
30
|
+
canonical transcript identically instead of re-deriving the index arithmetic
|
|
31
|
+
and drifting. Add a transcript-integrity guard in `applyTurnMetadata` that
|
|
32
|
+
logs when a finalize would leave the latest user message out of the
|
|
33
|
+
model-facing transcript.
|
|
34
|
+
|
|
3
35
|
## 0.61.0
|
|
4
36
|
|
|
5
37
|
### Minor Changes
|
package/dist/index.d.ts
CHANGED
|
@@ -2222,6 +2222,40 @@ declare const buildApprovalCheckpoints: ({ approvals, runId, checkpointMessages,
|
|
|
2222
2222
|
pendingToolCalls: PendingToolCall[];
|
|
2223
2223
|
kind?: "approval" | "device";
|
|
2224
2224
|
}) => NonNullable<Conversation["pendingApprovals"]>;
|
|
2225
|
+
/** Reconstruct the full canonical transcript a checkpoint resumes from: the
|
|
2226
|
+
* prior history (before the checkpointed turn) + the checkpoint delta.
|
|
2227
|
+
* Handles both storage conventions via `normalizeApprovalCheckpoint` — the
|
|
2228
|
+
* initial checkpoint (base = prior length, delta) and a resume-created one
|
|
2229
|
+
* (base 0, full canonical). */
|
|
2230
|
+
declare const assembleCheckpointMessages: (conversation: Conversation, checkpoint: StoredApproval) => Message[];
|
|
2231
|
+
/** Pair an assistant tool-call message's `tool_calls` with externally-computed
|
|
2232
|
+
* results into the single `role:"tool"` message a continuation reads.
|
|
2233
|
+
* Returns undefined when `assistantMsg` isn't an assistant message with
|
|
2234
|
+
* parseable tool calls. Used to persist the resume's canonical history so it
|
|
2235
|
+
* matches what `continueFromToolResult` fed the model. Missing results
|
|
2236
|
+
* default to the deferred-error marker (same text the run uses). */
|
|
2237
|
+
declare const buildToolResultMessage: (assistantMsg: Message | undefined, toolResults: Array<{
|
|
2238
|
+
callId: string;
|
|
2239
|
+
toolName: string;
|
|
2240
|
+
result?: unknown;
|
|
2241
|
+
error?: string;
|
|
2242
|
+
}>) => Message | undefined;
|
|
2243
|
+
/** Build the `pendingApprovals` rows for a checkpoint reached DURING a resume
|
|
2244
|
+
* continuation. Stores the WHOLE canonical history the continuation ran with
|
|
2245
|
+
* (`priorMessages` = prior + tool result + new delta) with
|
|
2246
|
+
* `baseMessageCount: 0`. Keeping the full history here — rather than a delta
|
|
2247
|
+
* + a base count into a different message array — is what lets the next
|
|
2248
|
+
* resume reconstruct with no index arithmetic. */
|
|
2249
|
+
declare const buildResumeCheckpoints: ({ priorMessages, checkpointEvent, runId, kind, }: {
|
|
2250
|
+
priorMessages: Message[];
|
|
2251
|
+
checkpointEvent: {
|
|
2252
|
+
approvals: ApprovalEventItem[];
|
|
2253
|
+
checkpointMessages: Message[];
|
|
2254
|
+
pendingToolCalls: PendingToolCall[];
|
|
2255
|
+
};
|
|
2256
|
+
runId: string;
|
|
2257
|
+
kind?: "approval" | "device";
|
|
2258
|
+
}) => NonNullable<Conversation["pendingApprovals"]>;
|
|
2225
2259
|
declare const applyTurnMetadata: (conv: Conversation, meta: TurnResultMetadata, opts?: {
|
|
2226
2260
|
clearContinuation?: boolean;
|
|
2227
2261
|
clearApprovals?: boolean;
|
|
@@ -2503,4 +2537,4 @@ type NewEntryNoId = NewConversationEntry extends infer T ? T extends NewConversa
|
|
|
2503
2537
|
*/
|
|
2504
2538
|
declare const appendEntriesSafe: (store: ConversationStore, conversation: Pick<Conversation, "conversationId" | "ownerId" | "tenantId">, entries: NewEntryNoId[], log: Logger) => Promise<ConversationEntry[]>;
|
|
2505
2539
|
|
|
2506
|
-
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 CallbackStartedEntry, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type ContinuationHooks, type Conversation, type ConversationCreateInit, type ConversationEntry, type ConversationState, type ConversationStatusSnapshot, type ConversationStore, type ConversationSummary, type CreateSkillToolsOptions, type CronJobConfig, DEFAULT_AGENT_DESCRIPTION, DEFAULT_AGENT_NAME, DEFAULT_MAX_STEPS, DEFAULT_MODEL_NAME, DEFAULT_MODEL_PROVIDER, DEFAULT_TAGLINE, DEFAULT_TEMPERATURE, DEFAULT_TIMEOUT, type DefaultAgentDefinitionOptions, 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 MountProvider, type NetworkConfig, type NewConversationEntry, 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 SubagentResultEntry, type SubagentSpawnResult, type SubagentSummary, type SubagentTaskOutcome, type SubagentTranscript, type SubagentTranscriptMode, 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, type VirtualMount, abnormalEndResponse, appendEntriesSafe, 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, decodeFileInputData, defaultAgentDefinition, deleteOpenAICodexSession, deriveUploadKey, ensureAgentIdentity, estimateTokens, estimateTotalTokens, executeConversationTurn, findSafeSplitPoint, findSafeSplitPointByTurns, flushTurnDraft, generateAgentId, getAgentStoreDirectory, getModelContextWindow, getOpenAICodexAccessToken, getOpenAICodexAuthFilePath, getOpenAICodexRequiredScopes, getPendingSubagentResults, getPonchoStoreRoot, isMessageArray, jsonSchemaToZod, lastAssistantText, loadCanonicalHistory, loadPonchoConfig, loadRunHistory, loadSkillContext, loadSkillInstructions, loadSkillMetadata, loadSkillMetadataFromDirs, loadVfsSkillMetadata, mergeSkills, normalizeApprovalCheckpoint, normalizeOtlp, normalizeScriptPolicyPath, normalizeToolAccess, parseAgentFile, parseAgentMarkdown, parseSkillFrontmatter, ponchoDocsTool, readOpenAICodexSession, readSkillResource, realResponseText, recordStandardTurnEvent, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveEnv, resolveMemoryConfig, resolveRunRequest, resolveSkillDirs, resolveStateConfig, runConversationTurn, slugifyStorageComponent, startOpenAICodexDeviceAuth, stripPillMetaTokens, verifyTenantToken, withToolResultArchiveParam, writeOpenAICodexSession };
|
|
2540
|
+
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 CallbackStartedEntry, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type ContinuationHooks, type Conversation, type ConversationCreateInit, type ConversationEntry, type ConversationState, type ConversationStatusSnapshot, type ConversationStore, type ConversationSummary, type CreateSkillToolsOptions, type CronJobConfig, DEFAULT_AGENT_DESCRIPTION, DEFAULT_AGENT_NAME, DEFAULT_MAX_STEPS, DEFAULT_MODEL_NAME, DEFAULT_MODEL_PROVIDER, DEFAULT_TAGLINE, DEFAULT_TEMPERATURE, DEFAULT_TIMEOUT, type DefaultAgentDefinitionOptions, 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 MountProvider, type NetworkConfig, type NewConversationEntry, 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 SubagentResultEntry, type SubagentSpawnResult, type SubagentSummary, type SubagentTaskOutcome, type SubagentTranscript, type SubagentTranscriptMode, 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, type VirtualMount, abnormalEndResponse, appendEntriesSafe, applyTurnMetadata, assembleCheckpointMessages, buildAgentDirectoryName, buildApprovalCheckpoints, buildAssistantMetadata, buildResumeCheckpoints, buildSkillContextWindow, buildToolCompletedText, buildToolResultMessage, 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, decodeFileInputData, defaultAgentDefinition, deleteOpenAICodexSession, deriveUploadKey, ensureAgentIdentity, estimateTokens, estimateTotalTokens, executeConversationTurn, findSafeSplitPoint, findSafeSplitPointByTurns, flushTurnDraft, generateAgentId, getAgentStoreDirectory, getModelContextWindow, getOpenAICodexAccessToken, getOpenAICodexAuthFilePath, getOpenAICodexRequiredScopes, getPendingSubagentResults, getPonchoStoreRoot, isMessageArray, jsonSchemaToZod, lastAssistantText, loadCanonicalHistory, loadPonchoConfig, loadRunHistory, loadSkillContext, loadSkillInstructions, loadSkillMetadata, loadSkillMetadataFromDirs, loadVfsSkillMetadata, mergeSkills, normalizeApprovalCheckpoint, normalizeOtlp, normalizeScriptPolicyPath, normalizeToolAccess, parseAgentFile, parseAgentMarkdown, parseSkillFrontmatter, ponchoDocsTool, readOpenAICodexSession, readSkillResource, realResponseText, recordStandardTurnEvent, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveEnv, resolveMemoryConfig, resolveRunRequest, resolveSkillDirs, resolveStateConfig, runConversationTurn, slugifyStorageComponent, startOpenAICodexDeviceAuth, stripPillMetaTokens, verifyTenantToken, withToolResultArchiveParam, writeOpenAICodexSession };
|
package/dist/index.js
CHANGED
|
@@ -11461,6 +11461,13 @@ ${textContent}` };
|
|
|
11461
11461
|
const tokensAfterCompaction = estimateTotalTokens(systemPrompt, messages, toolDefsJsonForEstimate);
|
|
11462
11462
|
latestContextTokens = tokensAfterCompaction;
|
|
11463
11463
|
toolOutputEstimateSinceModel = 0;
|
|
11464
|
+
trace.getActiveSpan()?.setAttributes({
|
|
11465
|
+
"poncho.compaction.occurred": true,
|
|
11466
|
+
"poncho.compaction.tokens_before": effectiveTokens,
|
|
11467
|
+
"poncho.compaction.tokens_after": tokensAfterCompaction,
|
|
11468
|
+
"poncho.compaction.context_window": contextWindow,
|
|
11469
|
+
"poncho.compaction.trigger": compactionConfig.trigger
|
|
11470
|
+
});
|
|
11464
11471
|
yield pushEvent({
|
|
11465
11472
|
type: "compaction:completed",
|
|
11466
11473
|
tokensBefore: effectiveTokens,
|
|
@@ -12772,6 +12779,7 @@ var resolveRunRequest = (conversation, request) => {
|
|
|
12772
12779
|
};
|
|
12773
12780
|
|
|
12774
12781
|
// src/orchestrator/turn.ts
|
|
12782
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
12775
12783
|
var createTurnDraftState = () => ({
|
|
12776
12784
|
assistantResponse: "",
|
|
12777
12785
|
toolTimeline: [],
|
|
@@ -12993,6 +13001,52 @@ var buildApprovalCheckpoints = ({
|
|
|
12993
13001
|
pendingToolCalls,
|
|
12994
13002
|
kind
|
|
12995
13003
|
}));
|
|
13004
|
+
var assembleCheckpointMessages = (conversation, checkpoint) => {
|
|
13005
|
+
const n = normalizeApprovalCheckpoint(checkpoint, conversation.messages);
|
|
13006
|
+
const base = n.baseMessageCount != null ? conversation.messages.slice(0, n.baseMessageCount) : [];
|
|
13007
|
+
return [...base, ...n.checkpointMessages ?? []];
|
|
13008
|
+
};
|
|
13009
|
+
var buildToolResultMessage = (assistantMsg, toolResults) => {
|
|
13010
|
+
if (assistantMsg?.role !== "assistant") return void 0;
|
|
13011
|
+
let toolCalls = [];
|
|
13012
|
+
try {
|
|
13013
|
+
const parsed = JSON.parse(typeof assistantMsg.content === "string" ? assistantMsg.content : "");
|
|
13014
|
+
toolCalls = parsed.tool_calls ?? [];
|
|
13015
|
+
} catch {
|
|
13016
|
+
return void 0;
|
|
13017
|
+
}
|
|
13018
|
+
if (toolCalls.length === 0) return void 0;
|
|
13019
|
+
const provided = new Map(toolResults.map((r) => [r.callId, r]));
|
|
13020
|
+
return {
|
|
13021
|
+
role: "tool",
|
|
13022
|
+
content: JSON.stringify(
|
|
13023
|
+
toolCalls.map((tc) => {
|
|
13024
|
+
const r = provided.get(tc.id);
|
|
13025
|
+
return {
|
|
13026
|
+
type: "tool_result",
|
|
13027
|
+
tool_use_id: tc.id,
|
|
13028
|
+
tool_name: r?.toolName ?? tc.name,
|
|
13029
|
+
content: r ? r.error ? `Tool error: ${r.error}` : JSON.stringify(r.result ?? null) : "Tool error: Tool execution deferred (pending approval checkpoint)"
|
|
13030
|
+
};
|
|
13031
|
+
})
|
|
13032
|
+
),
|
|
13033
|
+
metadata: { timestamp: Date.now(), id: randomUUID6() }
|
|
13034
|
+
};
|
|
13035
|
+
};
|
|
13036
|
+
var buildResumeCheckpoints = ({
|
|
13037
|
+
priorMessages,
|
|
13038
|
+
checkpointEvent,
|
|
13039
|
+
runId,
|
|
13040
|
+
kind = "approval"
|
|
13041
|
+
}) => buildApprovalCheckpoints({
|
|
13042
|
+
approvals: checkpointEvent.approvals,
|
|
13043
|
+
runId,
|
|
13044
|
+
checkpointMessages: [...priorMessages, ...checkpointEvent.checkpointMessages],
|
|
13045
|
+
baseMessageCount: 0,
|
|
13046
|
+
pendingToolCalls: checkpointEvent.pendingToolCalls,
|
|
13047
|
+
kind
|
|
13048
|
+
});
|
|
13049
|
+
var messageText = (m) => typeof m.content === "string" ? m.content : Array.isArray(m.content) ? m.content.map((p) => p.text ?? "").join("") : "";
|
|
12996
13050
|
var applyTurnMetadata = (conv, meta, opts = {}) => {
|
|
12997
13051
|
const {
|
|
12998
13052
|
clearContinuation = true,
|
|
@@ -13011,6 +13065,20 @@ var applyTurnMetadata = (conv, meta, opts = {}) => {
|
|
|
13011
13065
|
} else if (shouldRebuildCanonical) {
|
|
13012
13066
|
conv._harnessMessages = conv.messages;
|
|
13013
13067
|
}
|
|
13068
|
+
if (isMessageArray(conv._harnessMessages) && conv._harnessMessages.length > 0) {
|
|
13069
|
+
const canonical = conv._harnessMessages;
|
|
13070
|
+
const summarized = canonical.some((m) => m.metadata?.isCompactionSummary);
|
|
13071
|
+
const lastUser = [...conv.messages].reverse().find((m) => m.role === "user");
|
|
13072
|
+
const lastUserText = lastUser ? messageText(lastUser).trim() : "";
|
|
13073
|
+
if (!summarized && lastUserText) {
|
|
13074
|
+
const present = canonical.some((m) => m.role === "user" && messageText(m).trim() === lastUserText);
|
|
13075
|
+
if (!present) {
|
|
13076
|
+
console.error(
|
|
13077
|
+
`[transcript-guard] conversation ${conv.conversationId}: model-facing transcript is missing the latest user message \u2014 it diverged from the display transcript. This is a resume/finalize message-assembly bug; the model will not see that turn's input.`
|
|
13078
|
+
);
|
|
13079
|
+
}
|
|
13080
|
+
}
|
|
13081
|
+
}
|
|
13014
13082
|
if (meta.toolResultArchive !== void 0) {
|
|
13015
13083
|
conv._toolResultArchive = meta.toolResultArchive;
|
|
13016
13084
|
}
|
|
@@ -13041,12 +13109,12 @@ var STALE_SUBAGENT_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
|
13041
13109
|
import { createLogger as createLogger8, getTextContent as getTextContent3 } from "@poncho-ai/sdk";
|
|
13042
13110
|
|
|
13043
13111
|
// src/orchestrator/entries-dual-write.ts
|
|
13044
|
-
import { randomUUID as
|
|
13112
|
+
import { randomUUID as randomUUID7 } from "crypto";
|
|
13045
13113
|
var appendEntriesSafe = async (store, conversation, entries, log2) => {
|
|
13046
13114
|
if (entries.length === 0) return [];
|
|
13047
13115
|
try {
|
|
13048
13116
|
const withIds = entries.map(
|
|
13049
|
-
(e) => ({ id:
|
|
13117
|
+
(e) => ({ id: randomUUID7(), ...e })
|
|
13050
13118
|
);
|
|
13051
13119
|
return await store.appendEntries(
|
|
13052
13120
|
conversation.conversationId,
|
|
@@ -13285,34 +13353,11 @@ var AgentOrchestrator = class {
|
|
|
13285
13353
|
});
|
|
13286
13354
|
let latestRunId = conversation.runtimeRunId ?? "";
|
|
13287
13355
|
let checkpointedRun = false;
|
|
13288
|
-
const
|
|
13289
|
-
const
|
|
13290
|
-
|
|
13291
|
-
|
|
13292
|
-
|
|
13293
|
-
if (lastCpMsg?.role === "assistant") {
|
|
13294
|
-
try {
|
|
13295
|
-
const parsed = JSON.parse(typeof lastCpMsg.content === "string" ? lastCpMsg.content : "");
|
|
13296
|
-
const cpToolCalls = parsed.tool_calls ?? [];
|
|
13297
|
-
if (cpToolCalls.length > 0) {
|
|
13298
|
-
const providedMap = new Map(toolResults.map((r) => [r.callId, r]));
|
|
13299
|
-
resumeToolResultMsg = {
|
|
13300
|
-
role: "tool",
|
|
13301
|
-
content: JSON.stringify(cpToolCalls.map((tc) => {
|
|
13302
|
-
const provided = providedMap.get(tc.id);
|
|
13303
|
-
return {
|
|
13304
|
-
type: "tool_result",
|
|
13305
|
-
tool_use_id: tc.id,
|
|
13306
|
-
tool_name: provided?.toolName ?? tc.name,
|
|
13307
|
-
content: provided ? provided.error ? `Tool error: ${provided.error}` : JSON.stringify(provided.result ?? null) : "Tool error: Tool execution deferred (pending approval checkpoint)"
|
|
13308
|
-
};
|
|
13309
|
-
})),
|
|
13310
|
-
metadata: { timestamp: Date.now() }
|
|
13311
|
-
};
|
|
13312
|
-
}
|
|
13313
|
-
} catch {
|
|
13314
|
-
}
|
|
13315
|
-
}
|
|
13356
|
+
const fullCheckpointMessages = assembleCheckpointMessages(conversation, checkpoint);
|
|
13357
|
+
const resumeToolResultMsg = buildToolResultMessage(
|
|
13358
|
+
fullCheckpointMessages[fullCheckpointMessages.length - 1],
|
|
13359
|
+
toolResults
|
|
13360
|
+
);
|
|
13316
13361
|
const fullCheckpointWithResults = resumeToolResultMsg ? [...fullCheckpointMessages, resumeToolResultMsg] : fullCheckpointMessages;
|
|
13317
13362
|
let draftRef;
|
|
13318
13363
|
let execution;
|
|
@@ -13357,12 +13402,10 @@ var AgentOrchestrator = class {
|
|
|
13357
13402
|
const cpEvent = event;
|
|
13358
13403
|
const conv = await this.conversationStore.get(conversationId);
|
|
13359
13404
|
if (conv) {
|
|
13360
|
-
conv.pendingApprovals =
|
|
13361
|
-
|
|
13362
|
-
|
|
13363
|
-
|
|
13364
|
-
baseMessageCount: 0,
|
|
13365
|
-
pendingToolCalls: cpEvent.pendingToolCalls
|
|
13405
|
+
conv.pendingApprovals = buildResumeCheckpoints({
|
|
13406
|
+
priorMessages: fullCheckpointWithResults,
|
|
13407
|
+
checkpointEvent: cpEvent,
|
|
13408
|
+
runId: latestRunId
|
|
13366
13409
|
});
|
|
13367
13410
|
conv.updatedAt = Date.now();
|
|
13368
13411
|
await this.conversationStore.update(conv);
|
|
@@ -13671,7 +13714,17 @@ var AgentOrchestrator = class {
|
|
|
13671
13714
|
messages: harnessMessages,
|
|
13672
13715
|
abortSignal: childAbortController.signal,
|
|
13673
13716
|
// Inherit the parent run's telemetry choice (e.g. incognito).
|
|
13674
|
-
suppressTelemetry: conversation.subagentMeta?.suppressTelemetry
|
|
13717
|
+
suppressTelemetry: conversation.subagentMeta?.suppressTelemetry,
|
|
13718
|
+
// Distinguish subagent turns from the parent chat/job on the trace:
|
|
13719
|
+
// `poncho.run.kind` is the queryable attribute PonchOS already sets on
|
|
13720
|
+
// foreground/job runs, and `latitude.tags` (JSON-array string) is what
|
|
13721
|
+
// Latitude reads into its filterable tags field, so subagent spend can
|
|
13722
|
+
// be segmented out. `latitude.metadata` links back to the parent.
|
|
13723
|
+
telemetryAttributes: {
|
|
13724
|
+
"poncho.run.kind": "subagent",
|
|
13725
|
+
"latitude.tags": '["subagent"]',
|
|
13726
|
+
...conversation.parentConversationId ? { "latitude.metadata": JSON.stringify({ parentConversationId: conversation.parentConversationId }) } : {}
|
|
13727
|
+
}
|
|
13675
13728
|
})) {
|
|
13676
13729
|
if (event.type === "run:started") {
|
|
13677
13730
|
latestRunId = event.runId;
|
|
@@ -14164,7 +14217,14 @@ ${resultBody}`,
|
|
|
14164
14217
|
messages: continuationMessages,
|
|
14165
14218
|
abortSignal: childAbortController.signal,
|
|
14166
14219
|
// Inherit the parent run's telemetry choice (e.g. incognito).
|
|
14167
|
-
suppressTelemetry: conversation.subagentMeta?.suppressTelemetry
|
|
14220
|
+
suppressTelemetry: conversation.subagentMeta?.suppressTelemetry,
|
|
14221
|
+
// Tag the subagent continuation the same way as the initial run (see
|
|
14222
|
+
// the fan-out call above) so its spend segments out of the parent's.
|
|
14223
|
+
telemetryAttributes: {
|
|
14224
|
+
"poncho.run.kind": "subagent",
|
|
14225
|
+
"latitude.tags": '["subagent"]',
|
|
14226
|
+
"latitude.metadata": JSON.stringify({ parentConversationId })
|
|
14227
|
+
}
|
|
14168
14228
|
})) {
|
|
14169
14229
|
if (event.type === "run:started") {
|
|
14170
14230
|
const active = this.activeConversationRuns.get(conversationId);
|
|
@@ -14610,7 +14670,7 @@ ${resultBody}`,
|
|
|
14610
14670
|
};
|
|
14611
14671
|
|
|
14612
14672
|
// src/orchestrator/run-conversation-turn.ts
|
|
14613
|
-
import { randomUUID as
|
|
14673
|
+
import { randomUUID as randomUUID8 } from "crypto";
|
|
14614
14674
|
import { createLogger as createLogger9 } from "@poncho-ai/sdk";
|
|
14615
14675
|
var log = createLogger9("orchestrator");
|
|
14616
14676
|
var runConversationTurn = async (opts) => {
|
|
@@ -14650,9 +14710,9 @@ var runConversationTurn = async (opts) => {
|
|
|
14650
14710
|
const userMessage = {
|
|
14651
14711
|
role: "user",
|
|
14652
14712
|
content: userContent,
|
|
14653
|
-
metadata: { id:
|
|
14713
|
+
metadata: { id: randomUUID8(), timestamp: turnTimestamp }
|
|
14654
14714
|
};
|
|
14655
|
-
const assistantId =
|
|
14715
|
+
const assistantId = randomUUID8();
|
|
14656
14716
|
const draft = createTurnDraftState();
|
|
14657
14717
|
let latestRunId = conversation.runtimeRunId ?? "";
|
|
14658
14718
|
let runCancelled = false;
|
|
@@ -15028,11 +15088,14 @@ export {
|
|
|
15028
15088
|
abnormalEndResponse,
|
|
15029
15089
|
appendEntriesSafe,
|
|
15030
15090
|
applyTurnMetadata,
|
|
15091
|
+
assembleCheckpointMessages,
|
|
15031
15092
|
buildAgentDirectoryName,
|
|
15032
15093
|
buildApprovalCheckpoints,
|
|
15033
15094
|
buildAssistantMetadata,
|
|
15095
|
+
buildResumeCheckpoints,
|
|
15034
15096
|
buildSkillContextWindow,
|
|
15035
15097
|
buildToolCompletedText,
|
|
15098
|
+
buildToolResultMessage,
|
|
15036
15099
|
cloneSections,
|
|
15037
15100
|
compactMessages,
|
|
15038
15101
|
completeOpenAICodexDeviceAuth,
|
package/package.json
CHANGED
package/src/harness.ts
CHANGED
|
@@ -3115,6 +3115,21 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
|
|
|
3115
3115
|
const tokensAfterCompaction = estimateTotalTokens(systemPrompt, messages, toolDefsJsonForEstimate);
|
|
3116
3116
|
latestContextTokens = tokensAfterCompaction;
|
|
3117
3117
|
toolOutputEstimateSinceModel = 0;
|
|
3118
|
+
// Stamp the compaction onto the active OTel span (the invoke_agent
|
|
3119
|
+
// root, active here because run() executes inside its context) so
|
|
3120
|
+
// observability backends can find compacted turns and measure the
|
|
3121
|
+
// cost they cause — a compaction rewrites the message prefix, which
|
|
3122
|
+
// wipes the per-model prompt cache and forces the next turn to
|
|
3123
|
+
// re-create a large cached span cold. Without this, "which turns
|
|
3124
|
+
// compacted, and what did it cost" is invisible at the span level.
|
|
3125
|
+
// No-op when telemetry is off (no active span).
|
|
3126
|
+
trace.getActiveSpan()?.setAttributes({
|
|
3127
|
+
"poncho.compaction.occurred": true,
|
|
3128
|
+
"poncho.compaction.tokens_before": effectiveTokens,
|
|
3129
|
+
"poncho.compaction.tokens_after": tokensAfterCompaction,
|
|
3130
|
+
"poncho.compaction.context_window": contextWindow,
|
|
3131
|
+
"poncho.compaction.trigger": compactionConfig.trigger,
|
|
3132
|
+
});
|
|
3118
3133
|
yield pushEvent({
|
|
3119
3134
|
type: "compaction:completed",
|
|
3120
3135
|
tokensBefore: effectiveTokens,
|
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
applyTurnMetadata,
|
|
11
11
|
normalizeApprovalCheckpoint,
|
|
12
12
|
buildApprovalCheckpoints,
|
|
13
|
+
assembleCheckpointMessages,
|
|
14
|
+
buildToolResultMessage,
|
|
15
|
+
buildResumeCheckpoints,
|
|
13
16
|
createTurnDraftState,
|
|
14
17
|
recordStandardTurnEvent,
|
|
15
18
|
type TurnDraftState,
|
|
@@ -421,38 +424,11 @@ export class AgentOrchestrator {
|
|
|
421
424
|
let latestRunId = conversation.runtimeRunId ?? "";
|
|
422
425
|
let checkpointedRun = false;
|
|
423
426
|
|
|
424
|
-
const
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
let resumeToolResultMsg: Message | undefined;
|
|
431
|
-
const lastCpMsg = fullCheckpointMessages[fullCheckpointMessages.length - 1];
|
|
432
|
-
if (lastCpMsg?.role === "assistant") {
|
|
433
|
-
try {
|
|
434
|
-
const parsed = JSON.parse(typeof lastCpMsg.content === "string" ? lastCpMsg.content : "");
|
|
435
|
-
const cpToolCalls: Array<{ id: string; name: string }> = parsed.tool_calls ?? [];
|
|
436
|
-
if (cpToolCalls.length > 0) {
|
|
437
|
-
const providedMap = new Map(toolResults.map(r => [r.callId, r]));
|
|
438
|
-
resumeToolResultMsg = {
|
|
439
|
-
role: "tool",
|
|
440
|
-
content: JSON.stringify(cpToolCalls.map(tc => {
|
|
441
|
-
const provided = providedMap.get(tc.id);
|
|
442
|
-
return {
|
|
443
|
-
type: "tool_result",
|
|
444
|
-
tool_use_id: tc.id,
|
|
445
|
-
tool_name: provided?.toolName ?? tc.name,
|
|
446
|
-
content: provided
|
|
447
|
-
? (provided.error ? `Tool error: ${provided.error}` : JSON.stringify(provided.result ?? null))
|
|
448
|
-
: "Tool error: Tool execution deferred (pending approval checkpoint)",
|
|
449
|
-
};
|
|
450
|
-
})),
|
|
451
|
-
metadata: { timestamp: Date.now() },
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
} catch { /* last message is not a parseable assistant-with-tools -- skip */ }
|
|
455
|
-
}
|
|
427
|
+
const fullCheckpointMessages = assembleCheckpointMessages(conversation, checkpoint);
|
|
428
|
+
const resumeToolResultMsg = buildToolResultMessage(
|
|
429
|
+
fullCheckpointMessages[fullCheckpointMessages.length - 1],
|
|
430
|
+
toolResults,
|
|
431
|
+
);
|
|
456
432
|
const fullCheckpointWithResults = resumeToolResultMsg
|
|
457
433
|
? [...fullCheckpointMessages, resumeToolResultMsg]
|
|
458
434
|
: fullCheckpointMessages;
|
|
@@ -505,12 +481,10 @@ export class AgentOrchestrator {
|
|
|
505
481
|
};
|
|
506
482
|
const conv = await this.conversationStore.get(conversationId);
|
|
507
483
|
if (conv) {
|
|
508
|
-
conv.pendingApprovals =
|
|
509
|
-
|
|
484
|
+
conv.pendingApprovals = buildResumeCheckpoints({
|
|
485
|
+
priorMessages: fullCheckpointWithResults,
|
|
486
|
+
checkpointEvent: cpEvent,
|
|
510
487
|
runId: latestRunId,
|
|
511
|
-
checkpointMessages: [...fullCheckpointWithResults, ...cpEvent.checkpointMessages],
|
|
512
|
-
baseMessageCount: 0,
|
|
513
|
-
pendingToolCalls: cpEvent.pendingToolCalls,
|
|
514
488
|
});
|
|
515
489
|
conv.updatedAt = Date.now();
|
|
516
490
|
await this.conversationStore.update(conv);
|
|
@@ -888,6 +862,18 @@ export class AgentOrchestrator {
|
|
|
888
862
|
abortSignal: childAbortController.signal,
|
|
889
863
|
// Inherit the parent run's telemetry choice (e.g. incognito).
|
|
890
864
|
suppressTelemetry: conversation.subagentMeta?.suppressTelemetry,
|
|
865
|
+
// Distinguish subagent turns from the parent chat/job on the trace:
|
|
866
|
+
// `poncho.run.kind` is the queryable attribute PonchOS already sets on
|
|
867
|
+
// foreground/job runs, and `latitude.tags` (JSON-array string) is what
|
|
868
|
+
// Latitude reads into its filterable tags field, so subagent spend can
|
|
869
|
+
// be segmented out. `latitude.metadata` links back to the parent.
|
|
870
|
+
telemetryAttributes: {
|
|
871
|
+
"poncho.run.kind": "subagent",
|
|
872
|
+
"latitude.tags": "[\"subagent\"]",
|
|
873
|
+
...(conversation.parentConversationId
|
|
874
|
+
? { "latitude.metadata": JSON.stringify({ parentConversationId: conversation.parentConversationId }) }
|
|
875
|
+
: {}),
|
|
876
|
+
},
|
|
891
877
|
})) {
|
|
892
878
|
if (event.type === "run:started") {
|
|
893
879
|
latestRunId = event.runId;
|
|
@@ -1478,6 +1464,13 @@ export class AgentOrchestrator {
|
|
|
1478
1464
|
abortSignal: childAbortController.signal,
|
|
1479
1465
|
// Inherit the parent run's telemetry choice (e.g. incognito).
|
|
1480
1466
|
suppressTelemetry: conversation.subagentMeta?.suppressTelemetry,
|
|
1467
|
+
// Tag the subagent continuation the same way as the initial run (see
|
|
1468
|
+
// the fan-out call above) so its spend segments out of the parent's.
|
|
1469
|
+
telemetryAttributes: {
|
|
1470
|
+
"poncho.run.kind": "subagent",
|
|
1471
|
+
"latitude.tags": "[\"subagent\"]",
|
|
1472
|
+
"latitude.metadata": JSON.stringify({ parentConversationId }),
|
|
1473
|
+
},
|
|
1481
1474
|
})) {
|
|
1482
1475
|
if (event.type === "run:started") {
|
|
1483
1476
|
const active = this.activeConversationRuns.get(conversationId);
|
package/src/orchestrator/turn.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import type { AgentEvent, Message } from "@poncho-ai/sdk";
|
|
2
3
|
import type { Conversation } from "../state.js";
|
|
3
4
|
import type { AgentHarness } from "../harness.js";
|
|
@@ -341,6 +342,110 @@ export const buildApprovalCheckpoints = ({
|
|
|
341
342
|
kind,
|
|
342
343
|
}));
|
|
343
344
|
|
|
345
|
+
// ── Checkpoint transcript reconstruction (single source of truth) ──
|
|
346
|
+
//
|
|
347
|
+
// Resuming a checkpointed turn requires rebuilding the FULL canonical model
|
|
348
|
+
// transcript from what was stored at the pause. Getting this right — in the
|
|
349
|
+
// canonical message space, not the display one — is subtle, and re-deriving
|
|
350
|
+
// it by hand in each embedder is exactly how the model-facing transcript
|
|
351
|
+
// drifted from the display transcript and silently dropped a turn's user
|
|
352
|
+
// message after an approval. These helpers are that logic, exported so the
|
|
353
|
+
// orchestrator's own resume AND external embedders (e.g. PonchOS, which
|
|
354
|
+
// executes gated tools itself) share one implementation that can't drift.
|
|
355
|
+
|
|
356
|
+
/** Reconstruct the full canonical transcript a checkpoint resumes from: the
|
|
357
|
+
* prior history (before the checkpointed turn) + the checkpoint delta.
|
|
358
|
+
* Handles both storage conventions via `normalizeApprovalCheckpoint` — the
|
|
359
|
+
* initial checkpoint (base = prior length, delta) and a resume-created one
|
|
360
|
+
* (base 0, full canonical). */
|
|
361
|
+
export const assembleCheckpointMessages = (
|
|
362
|
+
conversation: Conversation,
|
|
363
|
+
checkpoint: StoredApproval,
|
|
364
|
+
): Message[] => {
|
|
365
|
+
const n = normalizeApprovalCheckpoint(checkpoint, conversation.messages);
|
|
366
|
+
const base = n.baseMessageCount != null ? conversation.messages.slice(0, n.baseMessageCount) : [];
|
|
367
|
+
return [...base, ...(n.checkpointMessages ?? [])];
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/** Pair an assistant tool-call message's `tool_calls` with externally-computed
|
|
371
|
+
* results into the single `role:"tool"` message a continuation reads.
|
|
372
|
+
* Returns undefined when `assistantMsg` isn't an assistant message with
|
|
373
|
+
* parseable tool calls. Used to persist the resume's canonical history so it
|
|
374
|
+
* matches what `continueFromToolResult` fed the model. Missing results
|
|
375
|
+
* default to the deferred-error marker (same text the run uses). */
|
|
376
|
+
export const buildToolResultMessage = (
|
|
377
|
+
assistantMsg: Message | undefined,
|
|
378
|
+
toolResults: Array<{ callId: string; toolName: string; result?: unknown; error?: string }>,
|
|
379
|
+
): Message | undefined => {
|
|
380
|
+
if (assistantMsg?.role !== "assistant") return undefined;
|
|
381
|
+
let toolCalls: Array<{ id: string; name: string }> = [];
|
|
382
|
+
try {
|
|
383
|
+
const parsed = JSON.parse(typeof assistantMsg.content === "string" ? assistantMsg.content : "");
|
|
384
|
+
toolCalls = parsed.tool_calls ?? [];
|
|
385
|
+
} catch {
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
if (toolCalls.length === 0) return undefined;
|
|
389
|
+
const provided = new Map(toolResults.map((r) => [r.callId, r]));
|
|
390
|
+
return {
|
|
391
|
+
role: "tool",
|
|
392
|
+
content: JSON.stringify(
|
|
393
|
+
toolCalls.map((tc) => {
|
|
394
|
+
const r = provided.get(tc.id);
|
|
395
|
+
return {
|
|
396
|
+
type: "tool_result",
|
|
397
|
+
tool_use_id: tc.id,
|
|
398
|
+
tool_name: r?.toolName ?? tc.name,
|
|
399
|
+
content: r
|
|
400
|
+
? (r.error ? `Tool error: ${r.error}` : JSON.stringify(r.result ?? null))
|
|
401
|
+
: "Tool error: Tool execution deferred (pending approval checkpoint)",
|
|
402
|
+
};
|
|
403
|
+
}),
|
|
404
|
+
),
|
|
405
|
+
metadata: { timestamp: Date.now(), id: randomUUID() },
|
|
406
|
+
};
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
/** Build the `pendingApprovals` rows for a checkpoint reached DURING a resume
|
|
410
|
+
* continuation. Stores the WHOLE canonical history the continuation ran with
|
|
411
|
+
* (`priorMessages` = prior + tool result + new delta) with
|
|
412
|
+
* `baseMessageCount: 0`. Keeping the full history here — rather than a delta
|
|
413
|
+
* + a base count into a different message array — is what lets the next
|
|
414
|
+
* resume reconstruct with no index arithmetic. */
|
|
415
|
+
export const buildResumeCheckpoints = ({
|
|
416
|
+
priorMessages,
|
|
417
|
+
checkpointEvent,
|
|
418
|
+
runId,
|
|
419
|
+
kind = "approval",
|
|
420
|
+
}: {
|
|
421
|
+
priorMessages: Message[];
|
|
422
|
+
checkpointEvent: {
|
|
423
|
+
approvals: ApprovalEventItem[];
|
|
424
|
+
checkpointMessages: Message[];
|
|
425
|
+
pendingToolCalls: PendingToolCall[];
|
|
426
|
+
};
|
|
427
|
+
runId: string;
|
|
428
|
+
kind?: "approval" | "device";
|
|
429
|
+
}): NonNullable<Conversation["pendingApprovals"]> =>
|
|
430
|
+
buildApprovalCheckpoints({
|
|
431
|
+
approvals: checkpointEvent.approvals,
|
|
432
|
+
runId,
|
|
433
|
+
checkpointMessages: [...priorMessages, ...checkpointEvent.checkpointMessages],
|
|
434
|
+
baseMessageCount: 0,
|
|
435
|
+
pendingToolCalls: checkpointEvent.pendingToolCalls,
|
|
436
|
+
kind,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
/** Text of a message for the transcript-integrity guard — flattens the two
|
|
440
|
+
* content shapes so a user message can be matched across the display and
|
|
441
|
+
* canonical transcripts regardless of how each stored it. */
|
|
442
|
+
const messageText = (m: Message): string =>
|
|
443
|
+
typeof m.content === "string"
|
|
444
|
+
? m.content
|
|
445
|
+
: Array.isArray(m.content)
|
|
446
|
+
? m.content.map((p) => (p as { text?: string }).text ?? "").join("")
|
|
447
|
+
: "";
|
|
448
|
+
|
|
344
449
|
// ── Turn metadata persistence ──
|
|
345
450
|
|
|
346
451
|
export const applyTurnMetadata = (
|
|
@@ -373,6 +478,31 @@ export const applyTurnMetadata = (
|
|
|
373
478
|
conv._harnessMessages = conv.messages;
|
|
374
479
|
}
|
|
375
480
|
|
|
481
|
+
// Invariant guard: the model-facing transcript must not drop the current
|
|
482
|
+
// turn's user message. A resume that reconstructed `_harnessMessages`
|
|
483
|
+
// incorrectly silently lost it (the display transcript kept it, the model
|
|
484
|
+
// didn't), so the agent second-guessed an approval it never saw. This is the
|
|
485
|
+
// one choke point every finalize flows through — assert the latest user
|
|
486
|
+
// message survived into canonical, and log loudly if not. Matched by content
|
|
487
|
+
// (robust across the display/canonical shapes) and skipped when compaction
|
|
488
|
+
// has legitimately summarized history. Log-only; never throws.
|
|
489
|
+
if (isMessageArray(conv._harnessMessages) && conv._harnessMessages.length > 0) {
|
|
490
|
+
const canonical = conv._harnessMessages;
|
|
491
|
+
const summarized = canonical.some((m) => m.metadata?.isCompactionSummary);
|
|
492
|
+
const lastUser = [...conv.messages].reverse().find((m) => m.role === "user");
|
|
493
|
+
const lastUserText = lastUser ? messageText(lastUser).trim() : "";
|
|
494
|
+
if (!summarized && lastUserText) {
|
|
495
|
+
const present = canonical.some((m) => m.role === "user" && messageText(m).trim() === lastUserText);
|
|
496
|
+
if (!present) {
|
|
497
|
+
console.error(
|
|
498
|
+
`[transcript-guard] conversation ${conv.conversationId}: model-facing transcript ` +
|
|
499
|
+
`is missing the latest user message — it diverged from the display transcript. ` +
|
|
500
|
+
`This is a resume/finalize message-assembly bug; the model will not see that turn's input.`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
376
506
|
if (meta.toolResultArchive !== undefined) {
|
|
377
507
|
conv._toolResultArchive = meta.toolResultArchive;
|
|
378
508
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { Message } from "@poncho-ai/sdk";
|
|
3
|
+
import {
|
|
4
|
+
assembleCheckpointMessages,
|
|
5
|
+
buildToolResultMessage,
|
|
6
|
+
buildResumeCheckpoints,
|
|
7
|
+
applyTurnMetadata,
|
|
8
|
+
type StoredApproval,
|
|
9
|
+
} from "../src/orchestrator/index.js";
|
|
10
|
+
import type { Conversation } from "../src/state.js";
|
|
11
|
+
|
|
12
|
+
const conv = (overrides: Partial<Conversation> & Pick<Conversation, "messages">): Conversation => ({
|
|
13
|
+
conversationId: "conv_test",
|
|
14
|
+
title: "test",
|
|
15
|
+
ownerId: "owner",
|
|
16
|
+
tenantId: null,
|
|
17
|
+
createdAt: Date.now(),
|
|
18
|
+
updatedAt: Date.now(),
|
|
19
|
+
...overrides,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// An assistant tool-call message is JSON `{ text, tool_calls: [...] }` in the
|
|
23
|
+
// canonical transcript.
|
|
24
|
+
const assistantToolCall = (text: string, calls: Array<{ id: string; name: string }>): Message => ({
|
|
25
|
+
role: "assistant",
|
|
26
|
+
content: JSON.stringify({ text, tool_calls: calls.map((c) => ({ ...c, input: {} })) }),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const meta = (harnessMessages?: Message[]) => ({
|
|
30
|
+
latestRunId: "",
|
|
31
|
+
contextTokens: 0,
|
|
32
|
+
contextWindow: 0,
|
|
33
|
+
harnessMessages,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("assembleCheckpointMessages", () => {
|
|
37
|
+
it("initial checkpoint: prior history (sliced by base) + delta", () => {
|
|
38
|
+
const prior: Message[] = [
|
|
39
|
+
{ role: "user", content: "hi" },
|
|
40
|
+
{ role: "assistant", content: "hello" },
|
|
41
|
+
];
|
|
42
|
+
// Task-bearing turn: the delta carries the turn's user message + the
|
|
43
|
+
// assistant preamble/tool-call. base = prior length.
|
|
44
|
+
const delta: Message[] = [
|
|
45
|
+
{ role: "user", content: "yeah those are a lot better" },
|
|
46
|
+
assistantToolCall("I found the two PE events…", [{ id: "call_1", name: "update_event" }]),
|
|
47
|
+
];
|
|
48
|
+
const checkpoint = { baseMessageCount: prior.length, checkpointMessages: delta } as StoredApproval;
|
|
49
|
+
const out = assembleCheckpointMessages(conv({ messages: prior }), checkpoint);
|
|
50
|
+
|
|
51
|
+
expect(out).toHaveLength(4);
|
|
52
|
+
expect(out.some((m) => m.role === "user" && m.content === "yeah those are a lot better")).toBe(true);
|
|
53
|
+
expect(out.some((m) => typeof m.content === "string" && m.content.includes("I found the two PE events"))).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("resume convention: base 0 + full canonical returns the full canonical verbatim", () => {
|
|
57
|
+
const full: Message[] = [
|
|
58
|
+
{ role: "user", content: "hi" },
|
|
59
|
+
{ role: "user", content: "approve this" },
|
|
60
|
+
assistantToolCall("preamble", [{ id: "call_1", name: "t" }]),
|
|
61
|
+
];
|
|
62
|
+
const checkpoint = { baseMessageCount: 0, checkpointMessages: full } as StoredApproval;
|
|
63
|
+
// Display messages are irrelevant when base is 0 — the reconstruction must
|
|
64
|
+
// NOT depend on them (this is what the old PonchOS hand-merge got wrong).
|
|
65
|
+
const out = assembleCheckpointMessages(conv({ messages: [{ role: "user", content: "unrelated display" }] }), checkpoint);
|
|
66
|
+
expect(out).toEqual(full);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("buildToolResultMessage", () => {
|
|
71
|
+
const asst = assistantToolCall("", [{ id: "call_1", name: "update_event" }]);
|
|
72
|
+
|
|
73
|
+
it("pairs a provided result by callId", () => {
|
|
74
|
+
const msg = buildToolResultMessage(asst, [{ callId: "call_1", toolName: "update_event", result: { ok: true } }]);
|
|
75
|
+
expect(msg?.role).toBe("tool");
|
|
76
|
+
expect(msg?.content).toContain("call_1");
|
|
77
|
+
expect(msg?.content).toContain("ok");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("emits the deferred-error marker when a result is missing", () => {
|
|
81
|
+
const msg = buildToolResultMessage(asst, []);
|
|
82
|
+
expect(msg?.content).toContain("Tool execution deferred");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns undefined for a non-assistant / non-tool-call message", () => {
|
|
86
|
+
expect(buildToolResultMessage({ role: "user", content: "hi" }, [])).toBeUndefined();
|
|
87
|
+
expect(buildToolResultMessage({ role: "assistant", content: "plain text" }, [])).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("buildResumeCheckpoints + round-trip (the regression)", () => {
|
|
92
|
+
it("stores full canonical (base 0) so the NEXT resume reconstructs without dropping the user turn", () => {
|
|
93
|
+
// The continuation ran with this full canonical (incl. the turn's user
|
|
94
|
+
// message + preamble) and appended a tool result.
|
|
95
|
+
const fullWithResults: Message[] = [
|
|
96
|
+
{ role: "user", content: "hi" },
|
|
97
|
+
{ role: "assistant", content: "hello" },
|
|
98
|
+
{ role: "user", content: "approve this" },
|
|
99
|
+
assistantToolCall("preamble text", [{ id: "call_1", name: "t1" }]),
|
|
100
|
+
{ role: "tool", content: JSON.stringify([{ type: "tool_result", tool_use_id: "call_1", content: "done" }]) },
|
|
101
|
+
];
|
|
102
|
+
// It then gated again on a new tool (the re-checkpoint event delta).
|
|
103
|
+
const delta2: Message[] = [assistantToolCall("", [{ id: "call_2", name: "t2" }])];
|
|
104
|
+
const stored = buildResumeCheckpoints({
|
|
105
|
+
priorMessages: fullWithResults,
|
|
106
|
+
checkpointEvent: {
|
|
107
|
+
approvals: [{ approvalId: "a2", tool: "t2", toolCallId: "call_2", input: {} }],
|
|
108
|
+
checkpointMessages: delta2,
|
|
109
|
+
pendingToolCalls: [{ id: "call_2", name: "t2", input: {} }],
|
|
110
|
+
},
|
|
111
|
+
runId: "run_2",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(stored[0]!.baseMessageCount).toBe(0);
|
|
115
|
+
expect(stored[0]!.checkpointMessages).toEqual([...fullWithResults, ...delta2]);
|
|
116
|
+
|
|
117
|
+
// Next resume reconstructs from the stored checkpoint — display is
|
|
118
|
+
// deliberately unrelated to prove no dependence on it.
|
|
119
|
+
const reconstructed = assembleCheckpointMessages(
|
|
120
|
+
conv({ messages: [{ role: "user", content: "unrelated" }], pendingApprovals: stored }),
|
|
121
|
+
stored[0]!,
|
|
122
|
+
);
|
|
123
|
+
// The user turn + preamble that PonchOS used to drop MUST survive.
|
|
124
|
+
expect(reconstructed.some((m) => m.role === "user" && m.content === "approve this")).toBe(true);
|
|
125
|
+
expect(reconstructed.some((m) => typeof m.content === "string" && m.content.includes("preamble text"))).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("applyTurnMetadata transcript-integrity guard", () => {
|
|
130
|
+
const latestUser: Message = { role: "user", content: "the latest question", metadata: { id: "u_latest" } };
|
|
131
|
+
|
|
132
|
+
it("logs when the latest user message is missing from the canonical transcript", () => {
|
|
133
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
134
|
+
const c = conv({ messages: [{ role: "assistant", content: "earlier" }, latestUser] });
|
|
135
|
+
// Canonical is missing "the latest question" — the exact divergence.
|
|
136
|
+
applyTurnMetadata(c, meta([{ role: "user", content: "hi" }, { role: "assistant", content: "x" }]));
|
|
137
|
+
expect(spy).toHaveBeenCalledWith(expect.stringContaining("[transcript-guard]"));
|
|
138
|
+
spy.mockRestore();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("stays silent when the latest user message is present in canonical", () => {
|
|
142
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
143
|
+
const c = conv({ messages: [latestUser] });
|
|
144
|
+
applyTurnMetadata(c, meta([{ role: "user", content: "the latest question" }, { role: "assistant", content: "reply" }]));
|
|
145
|
+
expect(spy).not.toHaveBeenCalled();
|
|
146
|
+
spy.mockRestore();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("skips the check when canonical was legitimately compacted", () => {
|
|
150
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
151
|
+
const c = conv({ messages: [latestUser] });
|
|
152
|
+
applyTurnMetadata(c, meta([{ role: "assistant", content: "summary", metadata: { isCompactionSummary: true } }]));
|
|
153
|
+
expect(spy).not.toHaveBeenCalled();
|
|
154
|
+
spy.mockRestore();
|
|
155
|
+
});
|
|
156
|
+
});
|