@poncho-ai/harness 0.60.1 → 0.61.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.
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +34 -0
- package/dist/index.d.ts +58 -1
- package/dist/index.js +113 -22
- package/package.json +2 -2
- package/src/agent-parser.ts +15 -1
- package/src/compaction.ts +163 -15
- package/src/harness.ts +18 -2
- package/src/orchestrator/orchestrator.ts +64 -7
- package/src/orchestrator/run-conversation-turn.ts +7 -0
- package/src/state.ts +13 -0
- package/test/compaction.test.ts +124 -2
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @poncho-ai/harness@0.
|
|
2
|
+
> @poncho-ai/harness@0.61.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
|
[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[32m574.16 KB[39m
|
|
12
11
|
[32mESM[39m [1mdist/isolate-F2PPSUL6.js [22m[32m53.82 KB[39m
|
|
13
|
-
[32mESM[39m
|
|
12
|
+
[32mESM[39m [1mdist/index.js [22m[32m579.17 KB[39m
|
|
13
|
+
[32mESM[39m ⚡️ Build success in 267ms
|
|
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 8217ms
|
|
16
|
+
[32mDTS[39m [1mdist/index.d.ts [22m[32m108.62 KB[39m
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @poncho-ai/harness
|
|
2
2
|
|
|
3
|
+
## 0.61.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#192](https://github.com/cesr/poncho-ai/pull/192) [`bfd8012`](https://github.com/cesr/poncho-ai/commit/bfd80125ad71cd4401221c598faebf71afda2078) Thanks [@cesr](https://github.com/cesr)! - Fix context-compaction correctness so the agent stops losing context and
|
|
8
|
+
mislabeling failed work after a compaction:
|
|
9
|
+
- **Turn-based retention.** Compaction now preserves the last N whole _turns_
|
|
10
|
+
verbatim (new `compaction.keepRecentTurns`, default 4) instead of N messages,
|
|
11
|
+
which in tool-heavy turns collapsed to just the summary. The preserved side is
|
|
12
|
+
bounded by a token budget (≤ 50% of the context window) so keeping recent turns
|
|
13
|
+
can't leave the post-compaction context above the trigger (re-compaction
|
|
14
|
+
thrash / overflow). Adds exported `findSafeSplitPointByTurns`.
|
|
15
|
+
- **Faithful summaries.** The summarization output cap is raised 768 → 8192
|
|
16
|
+
tokens (768 physically truncated summaries mid-content); per-message truncation
|
|
17
|
+
1200 → 4000 chars, with a total summarizer-input budget that drops the oldest
|
|
18
|
+
non-error messages first. The prompt now requires a non-omittable "Unresolved
|
|
19
|
+
errors & failures" section, a "Pending promises" section, forbids claiming
|
|
20
|
+
unconfirmed completion, and preserves identifiers verbatim.
|
|
21
|
+
- **Structured subagent task outcome.** New `PendingSubagentResult.taskOutcome`
|
|
22
|
+
(`succeeded | failed | partial | unknown`) distinct from run status: a subagent
|
|
23
|
+
that ran but failed its task is no longer recorded as "completed". Subagents
|
|
24
|
+
self-report a machine-readable verdict; it is parsed deterministically
|
|
25
|
+
(defaulting to "unknown", never success) and rendered in the callback header
|
|
26
|
+
and compaction ledger. The subagent digest is enlarged (2000 chars, ungated on
|
|
27
|
+
status) so the failure reason survives compaction.
|
|
28
|
+
- **Per-run `maxSteps` override.** New `RunInput.maxSteps` lets a caller raise the
|
|
29
|
+
step ceiling for foreground turns without raising it for background/job turns
|
|
30
|
+
that share the same agent definition.
|
|
31
|
+
|
|
32
|
+
### Patch Changes
|
|
33
|
+
|
|
34
|
+
- Updated dependencies [[`bfd8012`](https://github.com/cesr/poncho-ai/commit/bfd80125ad71cd4401221c598faebf71afda2078)]:
|
|
35
|
+
- @poncho-ai/sdk@1.17.0
|
|
36
|
+
|
|
3
37
|
## 0.60.1
|
|
4
38
|
|
|
5
39
|
### Patch Changes
|
package/dist/index.d.ts
CHANGED
|
@@ -43,6 +43,16 @@ interface AgentFrontmatter {
|
|
|
43
43
|
interface CompactionConfig {
|
|
44
44
|
enabled: boolean;
|
|
45
45
|
trigger: number;
|
|
46
|
+
/**
|
|
47
|
+
* Primary retention unit: number of whole recent *turns* (a user message plus
|
|
48
|
+
* the assistant/tool messages that follow it) preserved verbatim after the
|
|
49
|
+
* summary. See `findSafeSplitPointByTurns`.
|
|
50
|
+
*/
|
|
51
|
+
keepRecentTurns: number;
|
|
52
|
+
/**
|
|
53
|
+
* Fallback retention floor, in messages, used only for the single-giant-turn
|
|
54
|
+
* case where no turn boundary yields a safe split.
|
|
55
|
+
*/
|
|
46
56
|
keepRecentMessages: number;
|
|
47
57
|
instructions?: string;
|
|
48
58
|
}
|
|
@@ -105,8 +115,37 @@ declare const estimateTotalTokens: (systemPrompt: string, messages: Message[], t
|
|
|
105
115
|
* Returns -1 if no valid split point is found.
|
|
106
116
|
*/
|
|
107
117
|
declare const findSafeSplitPoint: (messages: Message[], keepRecentMessages: number) => number;
|
|
118
|
+
/**
|
|
119
|
+
* Find the split index that preserves the last `keepRecentTurns` whole *turns*
|
|
120
|
+
* verbatim. A turn begins at a `role:"user"` message and runs until the next
|
|
121
|
+
* `user` message. Everything before the split is folded into the summary;
|
|
122
|
+
* everything from the split onward is kept as-is.
|
|
123
|
+
*
|
|
124
|
+
* Two guards make a candidate split acceptable:
|
|
125
|
+
* 1. The compacted side has at least `MIN_COMPACTABLE_MESSAGES` messages.
|
|
126
|
+
* 2. The preserved side estimates at most `maxPreservedTokens` — so keeping N
|
|
127
|
+
* large turns can never leave the post-compaction context above the
|
|
128
|
+
* compaction trigger (which caused re-compaction thrash / window overflow).
|
|
129
|
+
* The tool-call-orphan guard (`splitOrphansToolCalls`) is preserved: a split
|
|
130
|
+
* that would strand an assistant tool-call on the compacted side steps to an
|
|
131
|
+
* earlier user boundary (which only preserves more, always safe).
|
|
132
|
+
*
|
|
133
|
+
* We try the largest N (up to `keepRecentTurns`) first and decrement, so we
|
|
134
|
+
* keep as many turns as fit. If no turn boundary yields a safe split (e.g. a
|
|
135
|
+
* single giant turn near the window), fall back to the message-based
|
|
136
|
+
* `findSafeSplitPoint` so the middle of that turn still compacts.
|
|
137
|
+
*
|
|
138
|
+
* Returns -1 when nothing safe can be compacted.
|
|
139
|
+
*/
|
|
140
|
+
declare const findSafeSplitPointByTurns: (messages: Message[], keepRecentTurns: number, keepRecentMessagesFallback: number, maxPreservedTokens: number) => number;
|
|
108
141
|
interface CompactMessagesOptions {
|
|
109
142
|
instructions?: string;
|
|
143
|
+
/**
|
|
144
|
+
* The model's context window (tokens). Used to bound the preserved side of a
|
|
145
|
+
* turn-based split so keeping N recent turns can't leave the post-compaction
|
|
146
|
+
* context above the compaction trigger. Defaults to 200k when omitted.
|
|
147
|
+
*/
|
|
148
|
+
contextWindow?: number;
|
|
110
149
|
}
|
|
111
150
|
interface CompactResult {
|
|
112
151
|
compacted: boolean;
|
|
@@ -207,10 +246,22 @@ interface StateStore {
|
|
|
207
246
|
set(state: ConversationState): Promise<void>;
|
|
208
247
|
delete(runId: string): Promise<void>;
|
|
209
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Whether a subagent actually accomplished its task — distinct from the *run*
|
|
251
|
+
* status (`status` below), which is "completed" whenever the run merely
|
|
252
|
+
* finished. A subagent that ran to completion but could not do the task (e.g.
|
|
253
|
+
* lacked the required tools) is `runStatus:"completed"` but `taskOutcome:
|
|
254
|
+
* "failed"`. Derived at subagent finalize; "unknown" when it can't be
|
|
255
|
+
* determined. Consumed by the compaction subagent ledger so a failed task can
|
|
256
|
+
* never be summarized as "completed".
|
|
257
|
+
*/
|
|
258
|
+
type SubagentTaskOutcome = "succeeded" | "failed" | "partial" | "unknown";
|
|
210
259
|
interface PendingSubagentResult {
|
|
211
260
|
subagentId: string;
|
|
212
261
|
task: string;
|
|
213
262
|
status: "completed" | "error" | "stopped";
|
|
263
|
+
/** Did the subagent accomplish its task? See {@link SubagentTaskOutcome}. */
|
|
264
|
+
taskOutcome: SubagentTaskOutcome;
|
|
214
265
|
result?: _poncho_ai_sdk.RunResult;
|
|
215
266
|
error?: _poncho_ai_sdk.AgentFailure;
|
|
216
267
|
timestamp: number;
|
|
@@ -2404,6 +2455,12 @@ interface RunConversationTurnOpts {
|
|
|
2404
2455
|
* built with an OTLP exporter attached.
|
|
2405
2456
|
*/
|
|
2406
2457
|
suppressTelemetry?: boolean;
|
|
2458
|
+
/**
|
|
2459
|
+
* Forwarded to `RunInput.maxSteps`: per-run step-ceiling override. Lets the
|
|
2460
|
+
* foreground chat turn run a higher ceiling than background/job turns that
|
|
2461
|
+
* share the same agent definition.
|
|
2462
|
+
*/
|
|
2463
|
+
maxSteps?: number;
|
|
2407
2464
|
/**
|
|
2408
2465
|
* Forwarded to `RunInput.telemetryAttributes` — extra attributes for the
|
|
2409
2466
|
* `invoke_agent` root span (e.g. run kind / job name for segmentation).
|
|
@@ -2446,4 +2503,4 @@ type NewEntryNoId = NewConversationEntry extends infer T ? T extends NewConversa
|
|
|
2446
2503
|
*/
|
|
2447
2504
|
declare const appendEntriesSafe: (store: ConversationStore, conversation: Pick<Conversation, "conversationId" | "ownerId" | "tenantId">, entries: NewEntryNoId[], log: Logger) => Promise<ConversationEntry[]>;
|
|
2448
2505
|
|
|
2449
|
-
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 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, 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 };
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -232,9 +232,10 @@ var parseAgentMarkdown = (content) => {
|
|
|
232
232
|
"Invalid AGENT.md frontmatter compaction.trigger: must be between 0.1 and 1."
|
|
233
233
|
);
|
|
234
234
|
}
|
|
235
|
+
const keepRecentTurns = typeof raw.keepRecentTurns === "number" ? Math.max(1, Math.floor(raw.keepRecentTurns)) : 4;
|
|
235
236
|
const keepRecentMessages = typeof raw.keepRecentMessages === "number" ? Math.max(2, Math.floor(raw.keepRecentMessages)) : 6;
|
|
236
237
|
const instructions = typeof raw.instructions === "string" && raw.instructions.trim() ? raw.instructions.trim() : void 0;
|
|
237
|
-
return { enabled, trigger, keepRecentMessages, instructions };
|
|
238
|
+
return { enabled, trigger, keepRecentTurns, keepRecentMessages, instructions };
|
|
238
239
|
})(),
|
|
239
240
|
cron: parseCronJobs(parsed.cron)
|
|
240
241
|
};
|
|
@@ -359,29 +360,37 @@ var MIN_COMPACTABLE_MESSAGES = 4;
|
|
|
359
360
|
var DEFAULT_COMPACTION_CONFIG = {
|
|
360
361
|
enabled: true,
|
|
361
362
|
trigger: 0.75,
|
|
362
|
-
|
|
363
|
+
keepRecentTurns: 4,
|
|
364
|
+
keepRecentMessages: 6
|
|
363
365
|
};
|
|
364
|
-
var SUMMARIZATION_MESSAGE_TRUNCATION_CHARS =
|
|
365
|
-
var SUMMARIZATION_MAX_OUTPUT_TOKENS =
|
|
366
|
+
var SUMMARIZATION_MESSAGE_TRUNCATION_CHARS = 4e3;
|
|
367
|
+
var SUMMARIZATION_MAX_OUTPUT_TOKENS = 8192;
|
|
368
|
+
var SUMMARIZATION_MAX_INPUT_TOKENS = 12e4;
|
|
366
369
|
var SUMMARIZATION_PROMPT = `Summarize the following conversation into a structured working state that allows continuation without re-asking questions. Include:
|
|
367
370
|
|
|
368
371
|
1. **User intent**: What the user originally asked for and any refinements
|
|
369
|
-
2. **Completed work**: What has been accomplished so far
|
|
372
|
+
2. **Completed work**: What has been accomplished AND CONFIRMED so far
|
|
370
373
|
3. **Key decisions**: Technical decisions made and their rationale
|
|
371
|
-
4. **
|
|
372
|
-
5. **
|
|
373
|
-
6. **
|
|
374
|
+
4. **Unresolved errors & failures**: Every error, failed or uncertain tool call, and failed or incomplete subagent \u2014 recorded verbatim enough to act on. NEVER drop a failure because it is old. This section is REQUIRED whenever any failure occurred; do not omit it.
|
|
375
|
+
5. **Pending promises**: Anything the assistant said it WOULD do but has not confirmed done.
|
|
376
|
+
6. **Referenced resources**: Files, URLs, tools, or data referenced
|
|
377
|
+
7. **Pending next steps**: What remains to be done
|
|
378
|
+
|
|
379
|
+
Rules:
|
|
380
|
+
- NEVER describe work as "completed", "done", or "fixed" unless the conversation contains explicit confirmation (a successful tool result or the user acknowledging it). If completion is unverified, record it under "Pending next steps" as unconfirmed.
|
|
381
|
+
- Preserve identifiers verbatim \u2014 file paths, URLs, IDs, command names, tool names. Do not paraphrase or truncate them.
|
|
374
382
|
|
|
375
383
|
Be concise but preserve all information needed to continue the task.
|
|
376
|
-
Omit any section that has no relevant content.`;
|
|
384
|
+
Omit any section that has no relevant content, EXCEPT "Unresolved errors & failures" when a failure occurred.`;
|
|
377
385
|
var CUMULATIVE_SUMMARY_PROMPT = `The FIRST message below (tagged [prior-summary]) is an existing working-state summary produced by an earlier compaction. Treat it as the authoritative prior working state: MERGE AND UPDATE it with the newer messages that follow it, carrying forward all still-relevant detail. Do NOT discard or re-compress information from the prior summary just because it is older \u2014 only drop it if the newer messages explicitly supersede it.`;
|
|
378
|
-
var SUBAGENT_DIGEST_CHARS =
|
|
386
|
+
var SUBAGENT_DIGEST_CHARS = 2e3;
|
|
379
387
|
var SUBAGENT_LEDGER_HEADING = "## Subagents";
|
|
380
388
|
var resolveCompactionConfig = (explicit) => {
|
|
381
389
|
if (!explicit) return { ...DEFAULT_COMPACTION_CONFIG };
|
|
382
390
|
return {
|
|
383
391
|
enabled: explicit.enabled ?? DEFAULT_COMPACTION_CONFIG.enabled,
|
|
384
392
|
trigger: explicit.trigger ?? DEFAULT_COMPACTION_CONFIG.trigger,
|
|
393
|
+
keepRecentTurns: explicit.keepRecentTurns ?? DEFAULT_COMPACTION_CONFIG.keepRecentTurns,
|
|
385
394
|
keepRecentMessages: explicit.keepRecentMessages ?? DEFAULT_COMPACTION_CONFIG.keepRecentMessages,
|
|
386
395
|
instructions: explicit.instructions
|
|
387
396
|
};
|
|
@@ -433,18 +442,57 @@ var findSafeSplitPoint = (messages, keepRecentMessages) => {
|
|
|
433
442
|
}
|
|
434
443
|
return -1;
|
|
435
444
|
};
|
|
445
|
+
var findSafeSplitPointByTurns = (messages, keepRecentTurns, keepRecentMessagesFallback, maxPreservedTokens) => {
|
|
446
|
+
const userIdx = [];
|
|
447
|
+
for (let i = 0; i < messages.length; i++) {
|
|
448
|
+
if (messages[i].role === "user") userIdx.push(i);
|
|
449
|
+
}
|
|
450
|
+
const maxN = Math.min(keepRecentTurns, userIdx.length);
|
|
451
|
+
for (let n = maxN; n >= 1; n--) {
|
|
452
|
+
let guardN = n;
|
|
453
|
+
let split = userIdx[userIdx.length - guardN];
|
|
454
|
+
while (splitOrphansToolCalls(messages, split) && guardN < userIdx.length) {
|
|
455
|
+
guardN++;
|
|
456
|
+
split = userIdx[userIdx.length - guardN];
|
|
457
|
+
}
|
|
458
|
+
if (split < MIN_COMPACTABLE_MESSAGES) continue;
|
|
459
|
+
const preservedTokens = estimateTokens(
|
|
460
|
+
messages.slice(split).map((m) => getTextContent(m)).join("\n")
|
|
461
|
+
);
|
|
462
|
+
if (preservedTokens <= maxPreservedTokens) return split;
|
|
463
|
+
}
|
|
464
|
+
return findSafeSplitPoint(messages, keepRecentMessagesFallback);
|
|
465
|
+
};
|
|
436
466
|
var isCompactionSummary = (msg) => msg.metadata?.isCompactionSummary === true;
|
|
467
|
+
var FAILURE_SIGNAL_RE = /\berror\b|\bfailed\b|\bfailure\b|\bexception\b|could ?n[’']?t|cannot\b|unable to|\[subagent result\]/i;
|
|
437
468
|
var buildSummarizationMessages = (messagesToCompact, instructions) => {
|
|
438
469
|
const hasPriorSummary = messagesToCompact.length > 0 && isCompactionSummary(messagesToCompact[0]);
|
|
439
|
-
const
|
|
470
|
+
const lines = [];
|
|
440
471
|
for (let i = 0; i < messagesToCompact.length; i++) {
|
|
441
472
|
const msg = messagesToCompact[i];
|
|
442
473
|
const text = getTextContent(msg);
|
|
443
474
|
const isPrior = i === 0 && hasPriorSummary;
|
|
444
475
|
const rendered = isPrior || text.length <= SUMMARIZATION_MESSAGE_TRUNCATION_CHARS ? text : text.slice(0, SUMMARIZATION_MESSAGE_TRUNCATION_CHARS) + "\n...[truncated]";
|
|
445
476
|
const tag = isPrior ? "prior-summary" : msg.role;
|
|
446
|
-
|
|
477
|
+
const line = `[${tag}]: ${rendered}`;
|
|
478
|
+
lines.push({
|
|
479
|
+
text: line,
|
|
480
|
+
tokens: estimateTokens(line),
|
|
481
|
+
important: isPrior || FAILURE_SIGNAL_RE.test(text)
|
|
482
|
+
});
|
|
447
483
|
}
|
|
484
|
+
let total = lines.reduce((sum, l) => sum + l.tokens, 0);
|
|
485
|
+
if (total > SUMMARIZATION_MAX_INPUT_TOKENS) {
|
|
486
|
+
for (let i = 0; i < lines.length && total > SUMMARIZATION_MAX_INPUT_TOKENS; ) {
|
|
487
|
+
if (lines[i].important) {
|
|
488
|
+
i++;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
total -= lines[i].tokens;
|
|
492
|
+
lines.splice(i, 1);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const conversationLines = lines.map((l) => l.text);
|
|
448
496
|
let prompt = SUMMARIZATION_PROMPT;
|
|
449
497
|
if (hasPriorSummary) prompt = `${prompt}
|
|
450
498
|
|
|
@@ -475,7 +523,7 @@ var parseSubagentCallback = (msg) => {
|
|
|
475
523
|
const subagentId = typeof meta.subagentId === "string" && meta.subagentId ? meta.subagentId : headerMatch?.[2] ?? "";
|
|
476
524
|
if (!subagentId) return null;
|
|
477
525
|
const task = typeof meta.task === "string" && meta.task ? meta.task : headerMatch?.[1] ?? "";
|
|
478
|
-
const status = headerMatch?.[3]
|
|
526
|
+
const status = typeof meta.taskOutcome === "string" && meta.taskOutcome || headerMatch?.[3] || "unknown";
|
|
479
527
|
const bodyStart = text.indexOf("\n\n");
|
|
480
528
|
const body = bodyStart >= 0 ? text.slice(bodyStart + 2) : text;
|
|
481
529
|
const digest = body.length > SUBAGENT_DIGEST_CHARS ? body.slice(0, SUBAGENT_DIGEST_CHARS) + "\u2026" : body;
|
|
@@ -535,8 +583,18 @@ ${summary}
|
|
|
535
583
|
Continue from where the conversation left off without re-asking questions.`,
|
|
536
584
|
metadata: { isCompactionSummary: true, timestamp: Date.now() }
|
|
537
585
|
});
|
|
586
|
+
var MAX_PRESERVED_CONTEXT_FRACTION = 0.5;
|
|
538
587
|
var compactMessages = async (model, messages, config, options) => {
|
|
539
|
-
const
|
|
588
|
+
const contextWindow = options?.contextWindow && options.contextWindow > 0 ? options.contextWindow : 2e5;
|
|
589
|
+
const maxPreservedTokens = Math.floor(
|
|
590
|
+
contextWindow * MAX_PRESERVED_CONTEXT_FRACTION
|
|
591
|
+
);
|
|
592
|
+
const splitIdx = findSafeSplitPointByTurns(
|
|
593
|
+
messages,
|
|
594
|
+
config.keepRecentTurns,
|
|
595
|
+
config.keepRecentMessages,
|
|
596
|
+
maxPreservedTokens
|
|
597
|
+
);
|
|
540
598
|
if (splitIdx === -1) {
|
|
541
599
|
return {
|
|
542
600
|
compacted: false,
|
|
@@ -10790,7 +10848,11 @@ var AgentHarness = class _AgentHarness {
|
|
|
10790
10848
|
const modelName = agent.frontmatter.model?.name ?? "claude-opus-4-5";
|
|
10791
10849
|
const modelInstance = this.modelProvider(modelName);
|
|
10792
10850
|
const config = resolveCompactionConfig(agent.frontmatter.compaction);
|
|
10793
|
-
|
|
10851
|
+
const contextWindow = agent.frontmatter.model?.contextWindow ?? getModelContextWindow(modelName);
|
|
10852
|
+
return compactMessages(modelInstance, messages, config, {
|
|
10853
|
+
...options,
|
|
10854
|
+
contextWindow: options?.contextWindow ?? contextWindow
|
|
10855
|
+
});
|
|
10794
10856
|
}
|
|
10795
10857
|
async *run(input) {
|
|
10796
10858
|
if (!this.parsedAgent) {
|
|
@@ -10811,7 +10873,7 @@ var AgentHarness = class _AgentHarness {
|
|
|
10811
10873
|
let agent = this.parsedAgent;
|
|
10812
10874
|
const runId = `run_${randomUUID5()}`;
|
|
10813
10875
|
const start = now();
|
|
10814
|
-
const maxSteps = agent.frontmatter.limits?.maxSteps ?? 20;
|
|
10876
|
+
const maxSteps = input.maxSteps ?? agent.frontmatter.limits?.maxSteps ?? 20;
|
|
10815
10877
|
const configuredTimeout = this.runTimeoutSecOverride ?? agent.frontmatter.limits?.timeout;
|
|
10816
10878
|
const timeoutMs = this.environment === "development" && configuredTimeout == null ? 0 : (configuredTimeout ?? 300) * 1e3;
|
|
10817
10879
|
const platformMaxDurationSec = Number(process.env.PONCHO_MAX_DURATION) || 0;
|
|
@@ -11383,7 +11445,8 @@ ${textContent}` };
|
|
|
11383
11445
|
const compactResult = await compactMessages(
|
|
11384
11446
|
modelInstance,
|
|
11385
11447
|
messages,
|
|
11386
|
-
compactionConfig
|
|
11448
|
+
compactionConfig,
|
|
11449
|
+
{ contextWindow }
|
|
11387
11450
|
);
|
|
11388
11451
|
if (compactResult.compacted) {
|
|
11389
11452
|
messages.length = 0;
|
|
@@ -13037,6 +13100,17 @@ var abnormalEndResponse = (opts) => {
|
|
|
13037
13100
|
|
|
13038
13101
|
${opts.gathered}` : `${head} ${recover}`;
|
|
13039
13102
|
};
|
|
13103
|
+
var SUBAGENT_OUTCOME_RE = /\[\[\s*OUTCOME\s*:\s*(succeeded|failed|partial)\s*\]\]/i;
|
|
13104
|
+
var SUBAGENT_OUTCOME_INSTRUCTION = "\n\nAt the very end of your final message, on its own line, append a machine-readable outcome verdict for the task you were given: `[[OUTCOME: succeeded]]`, `[[OUTCOME: partial]]`, or `[[OUTCOME: failed]]`, followed by a one-line reason. Judge honestly by whether YOU actually accomplished the task (produced the real result / wrote the files), not by whether the run finished. If a tool or capability you needed was missing, that is `failed`.";
|
|
13105
|
+
var deriveTaskOutcome = (gathered, abnormal) => {
|
|
13106
|
+
if (abnormal) return "failed";
|
|
13107
|
+
if (!gathered.trim()) return "failed";
|
|
13108
|
+
const m = gathered.match(SUBAGENT_OUTCOME_RE);
|
|
13109
|
+
if (!m) return "unknown";
|
|
13110
|
+
const v = m[1].toLowerCase();
|
|
13111
|
+
return v === "succeeded" ? "succeeded" : v === "partial" ? "partial" : "failed";
|
|
13112
|
+
};
|
|
13113
|
+
var stripOutcomeVerdict = (text) => text.replace(/\n*\[\[\s*OUTCOME\s*:\s*(?:succeeded|failed|partial)\s*\]\].*$/is, "").trimEnd();
|
|
13040
13114
|
var AgentOrchestrator = class {
|
|
13041
13115
|
harness;
|
|
13042
13116
|
conversationStore;
|
|
@@ -13480,6 +13554,7 @@ var AgentOrchestrator = class {
|
|
|
13480
13554
|
subagentId,
|
|
13481
13555
|
task: conv.subagentMeta?.task ?? conv.title,
|
|
13482
13556
|
status: "completed",
|
|
13557
|
+
taskOutcome: deriveTaskOutcome(responseText, false),
|
|
13483
13558
|
result: { status: "completed", response: responseText, steps: 0, tokens: { input: 0, output: 0, cached: 0 }, duration: 0 },
|
|
13484
13559
|
timestamp: Date.now()
|
|
13485
13560
|
};
|
|
@@ -13581,7 +13656,11 @@ var AgentOrchestrator = class {
|
|
|
13581
13656
|
const harnessMessages = [...runOutcome.messages];
|
|
13582
13657
|
const recallParams = this.hooks?.buildRecallParams?.({ ownerId, tenantId: conversation.tenantId, excludeConversationId: childConversationId }) ?? {};
|
|
13583
13658
|
for await (const event of childHarness.runWithTelemetry({
|
|
13584
|
-
task
|
|
13659
|
+
// Ask the subagent to self-report a machine-readable task outcome at the
|
|
13660
|
+
// end of its final message; `deriveTaskOutcome` parses it so a failed
|
|
13661
|
+
// task can never be recorded as "completed". Appended only to the model
|
|
13662
|
+
// input, not the stored/displayed task name.
|
|
13663
|
+
task: `${task}${SUBAGENT_OUTCOME_INSTRUCTION}`,
|
|
13585
13664
|
conversationId: childConversationId,
|
|
13586
13665
|
tenantId: conversation.tenantId ?? void 0,
|
|
13587
13666
|
parameters: withToolResultArchiveParam({
|
|
@@ -13768,11 +13847,14 @@ var AgentOrchestrator = class {
|
|
|
13768
13847
|
if (freshSubConv) gathered = realResponseText(lastAssistantText(freshSubConv.messages));
|
|
13769
13848
|
}
|
|
13770
13849
|
const abnormal = !runResult;
|
|
13771
|
-
const
|
|
13850
|
+
const taskOutcome = deriveTaskOutcome(gathered, abnormal);
|
|
13851
|
+
const cleanedGathered = stripOutcomeVerdict(gathered);
|
|
13852
|
+
const subagentResponse = abnormal ? abnormalEndResponse({ subagentId: childConversationId, gathered: cleanedGathered, runError }) : cleanedGathered;
|
|
13772
13853
|
const pendingResult = {
|
|
13773
13854
|
subagentId: childConversationId,
|
|
13774
13855
|
task,
|
|
13775
13856
|
status: abnormal ? "error" : "completed",
|
|
13857
|
+
taskOutcome,
|
|
13776
13858
|
result: {
|
|
13777
13859
|
status: runResult?.status ?? "error",
|
|
13778
13860
|
response: subagentResponse,
|
|
@@ -13813,6 +13895,7 @@ var AgentOrchestrator = class {
|
|
|
13813
13895
|
subagentId: childConversationId,
|
|
13814
13896
|
task,
|
|
13815
13897
|
status: "error",
|
|
13898
|
+
taskOutcome: "failed",
|
|
13816
13899
|
error: { code: "SUBAGENT_ERROR", message: errMsg },
|
|
13817
13900
|
timestamp: Date.now()
|
|
13818
13901
|
};
|
|
@@ -13880,10 +13963,10 @@ Response: ${responseLine}
|
|
|
13880
13963
|
Steps: ${pr.result.steps}, Duration: ${pr.result.duration}ms` : pr.error ? `Error: ${pr.error.message}` : "(no result)";
|
|
13881
13964
|
const injected = {
|
|
13882
13965
|
role: "user",
|
|
13883
|
-
content: `[Subagent Result] Subagent "${pr.task}" (${pr.subagentId}) ${pr.
|
|
13966
|
+
content: `[Subagent Result] Subagent "${pr.task}" (${pr.subagentId}) ${pr.taskOutcome}:
|
|
13884
13967
|
|
|
13885
13968
|
${resultBody}`,
|
|
13886
|
-
metadata: { _subagentCallback: true, subagentId: pr.subagentId, task: pr.task, timestamp: pr.timestamp }
|
|
13969
|
+
metadata: { _subagentCallback: true, subagentId: pr.subagentId, task: pr.task, taskOutcome: pr.taskOutcome, timestamp: pr.timestamp }
|
|
13887
13970
|
};
|
|
13888
13971
|
injectedCallbackMessages.push(injected);
|
|
13889
13972
|
conversation.messages.push(injected);
|
|
@@ -14166,13 +14249,16 @@ ${resultBody}`,
|
|
|
14166
14249
|
if (freshSubConv) gathered = realResponseText(lastAssistantText(freshSubConv.messages));
|
|
14167
14250
|
}
|
|
14168
14251
|
const abnormal = !runResult;
|
|
14169
|
-
const
|
|
14252
|
+
const taskOutcome = deriveTaskOutcome(gathered, abnormal);
|
|
14253
|
+
const cleanedGathered = stripOutcomeVerdict(gathered);
|
|
14254
|
+
const subagentResponse = abnormal ? abnormalEndResponse({ subagentId: conversationId, gathered: cleanedGathered, runError }) : cleanedGathered;
|
|
14170
14255
|
const parentConv = await this.conversationStore.get(parentConversationId);
|
|
14171
14256
|
if (parentConv) {
|
|
14172
14257
|
const result = {
|
|
14173
14258
|
subagentId: conversationId,
|
|
14174
14259
|
task,
|
|
14175
14260
|
status: abnormal ? "error" : "completed",
|
|
14261
|
+
taskOutcome,
|
|
14176
14262
|
result: { status: runResult?.status ?? "error", response: subagentResponse, steps: runResult?.steps ?? 0, tokens: { input: 0, output: 0, cached: 0 }, duration: runResult?.duration ?? 0 },
|
|
14177
14263
|
...abnormal ? { error: { code: runError?.code ?? "SUBAGENT_INCOMPLETE", message: runError?.message ?? "subagent ended without a result" } } : {},
|
|
14178
14264
|
timestamp: Date.now()
|
|
@@ -14221,6 +14307,7 @@ ${resultBody}`,
|
|
|
14221
14307
|
subagentId: conversationId,
|
|
14222
14308
|
task,
|
|
14223
14309
|
status: "error",
|
|
14310
|
+
taskOutcome: "failed",
|
|
14224
14311
|
error: { code: "CONTINUATION_ERROR", message: err instanceof Error ? err.message : String(err) },
|
|
14225
14312
|
timestamp: Date.now()
|
|
14226
14313
|
};
|
|
@@ -14464,6 +14551,7 @@ ${resultBody}`,
|
|
|
14464
14551
|
subagentId: childConversationId,
|
|
14465
14552
|
task,
|
|
14466
14553
|
status: "error",
|
|
14554
|
+
taskOutcome: "failed",
|
|
14467
14555
|
error: { code: "SUBAGENT_SPAWN_FAILED", message },
|
|
14468
14556
|
timestamp: Date.now()
|
|
14469
14557
|
});
|
|
@@ -14495,6 +14583,7 @@ ${resultBody}`,
|
|
|
14495
14583
|
subagentId: conv.conversationId,
|
|
14496
14584
|
task: conv.subagentMeta.task,
|
|
14497
14585
|
status: "error",
|
|
14586
|
+
taskOutcome: "failed",
|
|
14498
14587
|
error: conv.subagentMeta.error,
|
|
14499
14588
|
timestamp: Date.now()
|
|
14500
14589
|
};
|
|
@@ -14637,6 +14726,7 @@ var runConversationTurn = async (opts) => {
|
|
|
14637
14726
|
disablePromptCache: opts.disablePromptCache,
|
|
14638
14727
|
suppressTelemetry: opts.suppressTelemetry,
|
|
14639
14728
|
model: opts.model,
|
|
14729
|
+
maxSteps: opts.maxSteps,
|
|
14640
14730
|
volatileContext: opts.volatileContext,
|
|
14641
14731
|
telemetryAttributes: opts.telemetryAttributes
|
|
14642
14732
|
},
|
|
@@ -14981,6 +15071,7 @@ export {
|
|
|
14981
15071
|
estimateTotalTokens,
|
|
14982
15072
|
executeConversationTurn,
|
|
14983
15073
|
findSafeSplitPoint,
|
|
15074
|
+
findSafeSplitPointByTurns,
|
|
14984
15075
|
flushTurnDraft,
|
|
14985
15076
|
generateAgentId,
|
|
14986
15077
|
getAgentStoreDirectory,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poncho-ai/harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"mustache": "^4.2.0",
|
|
35
35
|
"yaml": "^2.4.0",
|
|
36
36
|
"zod": "^3.22.0",
|
|
37
|
-
"@poncho-ai/sdk": "1.
|
|
37
|
+
"@poncho-ai/sdk": "1.17.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"esbuild": ">=0.17.0",
|
package/src/agent-parser.ts
CHANGED
|
@@ -52,6 +52,16 @@ export interface AgentFrontmatter {
|
|
|
52
52
|
export interface CompactionConfig {
|
|
53
53
|
enabled: boolean;
|
|
54
54
|
trigger: number;
|
|
55
|
+
/**
|
|
56
|
+
* Primary retention unit: number of whole recent *turns* (a user message plus
|
|
57
|
+
* the assistant/tool messages that follow it) preserved verbatim after the
|
|
58
|
+
* summary. See `findSafeSplitPointByTurns`.
|
|
59
|
+
*/
|
|
60
|
+
keepRecentTurns: number;
|
|
61
|
+
/**
|
|
62
|
+
* Fallback retention floor, in messages, used only for the single-giant-turn
|
|
63
|
+
* case where no turn boundary yields a safe split.
|
|
64
|
+
*/
|
|
55
65
|
keepRecentMessages: number;
|
|
56
66
|
instructions?: string;
|
|
57
67
|
}
|
|
@@ -306,6 +316,10 @@ export const parseAgentMarkdown = (content: string): ParsedAgent => {
|
|
|
306
316
|
"Invalid AGENT.md frontmatter compaction.trigger: must be between 0.1 and 1.",
|
|
307
317
|
);
|
|
308
318
|
}
|
|
319
|
+
const keepRecentTurns =
|
|
320
|
+
typeof raw.keepRecentTurns === "number"
|
|
321
|
+
? Math.max(1, Math.floor(raw.keepRecentTurns))
|
|
322
|
+
: 4;
|
|
309
323
|
const keepRecentMessages =
|
|
310
324
|
typeof raw.keepRecentMessages === "number"
|
|
311
325
|
? Math.max(2, Math.floor(raw.keepRecentMessages))
|
|
@@ -314,7 +328,7 @@ export const parseAgentMarkdown = (content: string): ParsedAgent => {
|
|
|
314
328
|
typeof raw.instructions === "string" && raw.instructions.trim()
|
|
315
329
|
? raw.instructions.trim()
|
|
316
330
|
: undefined;
|
|
317
|
-
return { enabled, trigger, keepRecentMessages, instructions };
|
|
331
|
+
return { enabled, trigger, keepRecentTurns, keepRecentMessages, instructions };
|
|
318
332
|
})(),
|
|
319
333
|
cron: parseCronJobs(parsed.cron),
|
|
320
334
|
};
|
package/src/compaction.ts
CHANGED
|
@@ -9,22 +9,36 @@ const MIN_COMPACTABLE_MESSAGES = 4;
|
|
|
9
9
|
const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {
|
|
10
10
|
enabled: true,
|
|
11
11
|
trigger: 0.75,
|
|
12
|
-
|
|
12
|
+
keepRecentTurns: 4,
|
|
13
|
+
keepRecentMessages: 6,
|
|
13
14
|
};
|
|
14
|
-
const SUMMARIZATION_MESSAGE_TRUNCATION_CHARS =
|
|
15
|
-
const SUMMARIZATION_MAX_OUTPUT_TOKENS =
|
|
15
|
+
const SUMMARIZATION_MESSAGE_TRUNCATION_CHARS = 4000;
|
|
16
|
+
const SUMMARIZATION_MAX_OUTPUT_TOKENS = 8192;
|
|
17
|
+
/**
|
|
18
|
+
* Upper bound on the tokens fed INTO the summarization model call. With hundreds
|
|
19
|
+
* of compacted messages at 4000 chars each the joined transcript can exceed the
|
|
20
|
+
* summarizer's own window; we drop the oldest non-error, non-prior-summary
|
|
21
|
+
* messages first so recent context and every failure survive. Held well under a
|
|
22
|
+
* 200k window to leave room for the prompt + output.
|
|
23
|
+
*/
|
|
24
|
+
const SUMMARIZATION_MAX_INPUT_TOKENS = 120_000;
|
|
16
25
|
|
|
17
26
|
const SUMMARIZATION_PROMPT = `Summarize the following conversation into a structured working state that allows continuation without re-asking questions. Include:
|
|
18
27
|
|
|
19
28
|
1. **User intent**: What the user originally asked for and any refinements
|
|
20
|
-
2. **Completed work**: What has been accomplished so far
|
|
29
|
+
2. **Completed work**: What has been accomplished AND CONFIRMED so far
|
|
21
30
|
3. **Key decisions**: Technical decisions made and their rationale
|
|
22
|
-
4. **
|
|
23
|
-
5. **
|
|
24
|
-
6. **
|
|
31
|
+
4. **Unresolved errors & failures**: Every error, failed or uncertain tool call, and failed or incomplete subagent — recorded verbatim enough to act on. NEVER drop a failure because it is old. This section is REQUIRED whenever any failure occurred; do not omit it.
|
|
32
|
+
5. **Pending promises**: Anything the assistant said it WOULD do but has not confirmed done.
|
|
33
|
+
6. **Referenced resources**: Files, URLs, tools, or data referenced
|
|
34
|
+
7. **Pending next steps**: What remains to be done
|
|
35
|
+
|
|
36
|
+
Rules:
|
|
37
|
+
- NEVER describe work as "completed", "done", or "fixed" unless the conversation contains explicit confirmation (a successful tool result or the user acknowledging it). If completion is unverified, record it under "Pending next steps" as unconfirmed.
|
|
38
|
+
- Preserve identifiers verbatim — file paths, URLs, IDs, command names, tool names. Do not paraphrase or truncate them.
|
|
25
39
|
|
|
26
40
|
Be concise but preserve all information needed to continue the task.
|
|
27
|
-
Omit any section that has no relevant content.`;
|
|
41
|
+
Omit any section that has no relevant content, EXCEPT "Unresolved errors & failures" when a failure occurred.`;
|
|
28
42
|
|
|
29
43
|
/**
|
|
30
44
|
* Extra instruction appended when the first compacted message is itself a
|
|
@@ -34,8 +48,13 @@ Omit any section that has no relevant content.`;
|
|
|
34
48
|
*/
|
|
35
49
|
const CUMULATIVE_SUMMARY_PROMPT = `The FIRST message below (tagged [prior-summary]) is an existing working-state summary produced by an earlier compaction. Treat it as the authoritative prior working state: MERGE AND UPDATE it with the newer messages that follow it, carrying forward all still-relevant detail. Do NOT discard or re-compress information from the prior summary just because it is older — only drop it if the newer messages explicitly supersede it.`;
|
|
36
50
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Max chars of a subagent result text kept verbatim in the ledger digest. Kept
|
|
53
|
+
* large (and NOT gated on outcome) so a failed subagent's own explanation
|
|
54
|
+
* survives compaction — the failing case is exactly the one that must not be
|
|
55
|
+
* clipped, and it can be mislabeled, so gating on status would be self-defeating.
|
|
56
|
+
*/
|
|
57
|
+
const SUBAGENT_DIGEST_CHARS = 2000;
|
|
39
58
|
|
|
40
59
|
/** Heading used for the verbatim, model-proof subagent ledger block. */
|
|
41
60
|
const SUBAGENT_LEDGER_HEADING = "## Subagents";
|
|
@@ -47,6 +66,8 @@ export const resolveCompactionConfig = (
|
|
|
47
66
|
return {
|
|
48
67
|
enabled: explicit.enabled ?? DEFAULT_COMPACTION_CONFIG.enabled,
|
|
49
68
|
trigger: explicit.trigger ?? DEFAULT_COMPACTION_CONFIG.trigger,
|
|
69
|
+
keepRecentTurns:
|
|
70
|
+
explicit.keepRecentTurns ?? DEFAULT_COMPACTION_CONFIG.keepRecentTurns,
|
|
50
71
|
keepRecentMessages:
|
|
51
72
|
explicit.keepRecentMessages ??
|
|
52
73
|
DEFAULT_COMPACTION_CONFIG.keepRecentMessages,
|
|
@@ -162,6 +183,71 @@ export const findSafeSplitPoint = (
|
|
|
162
183
|
return -1;
|
|
163
184
|
};
|
|
164
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Find the split index that preserves the last `keepRecentTurns` whole *turns*
|
|
188
|
+
* verbatim. A turn begins at a `role:"user"` message and runs until the next
|
|
189
|
+
* `user` message. Everything before the split is folded into the summary;
|
|
190
|
+
* everything from the split onward is kept as-is.
|
|
191
|
+
*
|
|
192
|
+
* Two guards make a candidate split acceptable:
|
|
193
|
+
* 1. The compacted side has at least `MIN_COMPACTABLE_MESSAGES` messages.
|
|
194
|
+
* 2. The preserved side estimates at most `maxPreservedTokens` — so keeping N
|
|
195
|
+
* large turns can never leave the post-compaction context above the
|
|
196
|
+
* compaction trigger (which caused re-compaction thrash / window overflow).
|
|
197
|
+
* The tool-call-orphan guard (`splitOrphansToolCalls`) is preserved: a split
|
|
198
|
+
* that would strand an assistant tool-call on the compacted side steps to an
|
|
199
|
+
* earlier user boundary (which only preserves more, always safe).
|
|
200
|
+
*
|
|
201
|
+
* We try the largest N (up to `keepRecentTurns`) first and decrement, so we
|
|
202
|
+
* keep as many turns as fit. If no turn boundary yields a safe split (e.g. a
|
|
203
|
+
* single giant turn near the window), fall back to the message-based
|
|
204
|
+
* `findSafeSplitPoint` so the middle of that turn still compacts.
|
|
205
|
+
*
|
|
206
|
+
* Returns -1 when nothing safe can be compacted.
|
|
207
|
+
*/
|
|
208
|
+
export const findSafeSplitPointByTurns = (
|
|
209
|
+
messages: Message[],
|
|
210
|
+
keepRecentTurns: number,
|
|
211
|
+
keepRecentMessagesFallback: number,
|
|
212
|
+
maxPreservedTokens: number,
|
|
213
|
+
): number => {
|
|
214
|
+
// Indices of every user message, in order.
|
|
215
|
+
const userIdx: number[] = [];
|
|
216
|
+
for (let i = 0; i < messages.length; i++) {
|
|
217
|
+
if (messages[i]!.role === "user") userIdx.push(i);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const maxN = Math.min(keepRecentTurns, userIdx.length);
|
|
221
|
+
for (let n = maxN; n >= 1; n--) {
|
|
222
|
+
// The Nth-from-last user message starts the preserved region.
|
|
223
|
+
let guardN = n;
|
|
224
|
+
let split = userIdx[userIdx.length - guardN]!;
|
|
225
|
+
// Orphan guard: step to an earlier user boundary if the compacted side
|
|
226
|
+
// would end on an assistant tool-call whose result moved to the preserved
|
|
227
|
+
// side. Walking earlier only preserves more, so it stays within budget
|
|
228
|
+
// checks below.
|
|
229
|
+
while (
|
|
230
|
+
splitOrphansToolCalls(messages, split) &&
|
|
231
|
+
guardN < userIdx.length
|
|
232
|
+
) {
|
|
233
|
+
guardN++;
|
|
234
|
+
split = userIdx[userIdx.length - guardN]!;
|
|
235
|
+
}
|
|
236
|
+
if (split < MIN_COMPACTABLE_MESSAGES) continue;
|
|
237
|
+
const preservedTokens = estimateTokens(
|
|
238
|
+
messages
|
|
239
|
+
.slice(split)
|
|
240
|
+
.map((m) => getTextContent(m))
|
|
241
|
+
.join("\n"),
|
|
242
|
+
);
|
|
243
|
+
if (preservedTokens <= maxPreservedTokens) return split;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// No turn boundary fits: fall back to the legacy message-based split so a
|
|
247
|
+
// single giant turn still gets its middle compacted.
|
|
248
|
+
return findSafeSplitPoint(messages, keepRecentMessagesFallback);
|
|
249
|
+
};
|
|
250
|
+
|
|
165
251
|
/**
|
|
166
252
|
* Whether a message is itself a prior compaction summary.
|
|
167
253
|
*/
|
|
@@ -175,8 +261,17 @@ const isCompactionSummary = (msg: Message): boolean =>
|
|
|
175
261
|
* compaction summary, it is passed in FULL (not truncated to
|
|
176
262
|
* SUMMARIZATION_MESSAGE_TRUNCATION_CHARS) and tagged `[prior-summary]`, and
|
|
177
263
|
* the prompt instructs the model to merge-and-update rather than
|
|
178
|
-
* re-summarize. All other messages keep the
|
|
264
|
+
* re-summarize. All other messages keep the per-message truncation.
|
|
265
|
+
*
|
|
266
|
+
* Total-input budget: with many messages the joined transcript can exceed the
|
|
267
|
+
* summarizer's own window, so we cap it at SUMMARIZATION_MAX_INPUT_TOKENS and
|
|
268
|
+
* drop the OLDEST non-important lines first. A line is "important" (never
|
|
269
|
+
* dropped) if it is the prior summary or carries a failure/error signal — so
|
|
270
|
+
* every failure and the most recent context always reach the summarizer.
|
|
179
271
|
*/
|
|
272
|
+
const FAILURE_SIGNAL_RE =
|
|
273
|
+
/\berror\b|\bfailed\b|\bfailure\b|\bexception\b|could ?n[’']?t|cannot\b|unable to|\[subagent result\]/i;
|
|
274
|
+
|
|
180
275
|
const buildSummarizationMessages = (
|
|
181
276
|
messagesToCompact: Message[],
|
|
182
277
|
instructions?: string,
|
|
@@ -184,7 +279,12 @@ const buildSummarizationMessages = (
|
|
|
184
279
|
const hasPriorSummary =
|
|
185
280
|
messagesToCompact.length > 0 && isCompactionSummary(messagesToCompact[0]!);
|
|
186
281
|
|
|
187
|
-
|
|
282
|
+
interface Line {
|
|
283
|
+
text: string;
|
|
284
|
+
tokens: number;
|
|
285
|
+
important: boolean;
|
|
286
|
+
}
|
|
287
|
+
const lines: Line[] = [];
|
|
188
288
|
for (let i = 0; i < messagesToCompact.length; i++) {
|
|
189
289
|
const msg = messagesToCompact[i]!;
|
|
190
290
|
const text = getTextContent(msg);
|
|
@@ -196,9 +296,29 @@ const buildSummarizationMessages = (
|
|
|
196
296
|
: text.slice(0, SUMMARIZATION_MESSAGE_TRUNCATION_CHARS) +
|
|
197
297
|
"\n...[truncated]";
|
|
198
298
|
const tag = isPrior ? "prior-summary" : msg.role;
|
|
199
|
-
|
|
299
|
+
const line = `[${tag}]: ${rendered}`;
|
|
300
|
+
lines.push({
|
|
301
|
+
text: line,
|
|
302
|
+
tokens: estimateTokens(line),
|
|
303
|
+
important: isPrior || FAILURE_SIGNAL_RE.test(text),
|
|
304
|
+
});
|
|
200
305
|
}
|
|
201
306
|
|
|
307
|
+
// Enforce the input budget: while over, drop the oldest non-important line.
|
|
308
|
+
let total = lines.reduce((sum, l) => sum + l.tokens, 0);
|
|
309
|
+
if (total > SUMMARIZATION_MAX_INPUT_TOKENS) {
|
|
310
|
+
for (let i = 0; i < lines.length && total > SUMMARIZATION_MAX_INPUT_TOKENS; ) {
|
|
311
|
+
if (lines[i]!.important) {
|
|
312
|
+
i++;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
total -= lines[i]!.tokens;
|
|
316
|
+
lines.splice(i, 1);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const conversationLines = lines.map((l) => l.text);
|
|
321
|
+
|
|
202
322
|
let prompt = SUMMARIZATION_PROMPT;
|
|
203
323
|
if (hasPriorSummary) prompt = `${prompt}\n\n${CUMULATIVE_SUMMARY_PROMPT}`;
|
|
204
324
|
if (instructions) prompt = `${prompt}\n\nAdditional focus: ${instructions}`;
|
|
@@ -246,7 +366,15 @@ const parseSubagentCallback = (msg: Message): SubagentLedgerEntry | null => {
|
|
|
246
366
|
typeof meta.task === "string" && meta.task
|
|
247
367
|
? meta.task
|
|
248
368
|
: headerMatch?.[1] ?? "";
|
|
249
|
-
|
|
369
|
+
// The ledger "status" is the subagent's TASK OUTCOME (succeeded/failed/
|
|
370
|
+
// partial/unknown), not its run status. Prefer the structured metadata; fall
|
|
371
|
+
// back to the header token. NEVER default to "completed" — an unparseable
|
|
372
|
+
// header must not silently assert success. `status` remains the field name
|
|
373
|
+
// for backward compat with parsePriorLedger's rendered shape.
|
|
374
|
+
const status =
|
|
375
|
+
(typeof meta.taskOutcome === "string" && meta.taskOutcome) ||
|
|
376
|
+
headerMatch?.[3] ||
|
|
377
|
+
"unknown";
|
|
250
378
|
|
|
251
379
|
// Digest = the body after the header line (the result text), capped.
|
|
252
380
|
const bodyStart = text.indexOf("\n\n");
|
|
@@ -338,8 +466,17 @@ const buildContinuationMessage = (summary: string): Message => ({
|
|
|
338
466
|
|
|
339
467
|
export interface CompactMessagesOptions {
|
|
340
468
|
instructions?: string;
|
|
469
|
+
/**
|
|
470
|
+
* The model's context window (tokens). Used to bound the preserved side of a
|
|
471
|
+
* turn-based split so keeping N recent turns can't leave the post-compaction
|
|
472
|
+
* context above the compaction trigger. Defaults to 200k when omitted.
|
|
473
|
+
*/
|
|
474
|
+
contextWindow?: number;
|
|
341
475
|
}
|
|
342
476
|
|
|
477
|
+
/** Fraction of the context window the preserved (verbatim) side may occupy. */
|
|
478
|
+
const MAX_PRESERVED_CONTEXT_FRACTION = 0.5;
|
|
479
|
+
|
|
343
480
|
export interface CompactResult {
|
|
344
481
|
compacted: boolean;
|
|
345
482
|
messages: Message[];
|
|
@@ -363,7 +500,18 @@ export const compactMessages = async (
|
|
|
363
500
|
config: CompactionConfig,
|
|
364
501
|
options?: CompactMessagesOptions,
|
|
365
502
|
): Promise<CompactResult> => {
|
|
366
|
-
const
|
|
503
|
+
const contextWindow = options?.contextWindow && options.contextWindow > 0
|
|
504
|
+
? options.contextWindow
|
|
505
|
+
: 200_000;
|
|
506
|
+
const maxPreservedTokens = Math.floor(
|
|
507
|
+
contextWindow * MAX_PRESERVED_CONTEXT_FRACTION,
|
|
508
|
+
);
|
|
509
|
+
const splitIdx = findSafeSplitPointByTurns(
|
|
510
|
+
messages,
|
|
511
|
+
config.keepRecentTurns,
|
|
512
|
+
config.keepRecentMessages,
|
|
513
|
+
maxPreservedTokens,
|
|
514
|
+
);
|
|
367
515
|
if (splitIdx === -1) {
|
|
368
516
|
return {
|
|
369
517
|
compacted: false,
|
package/src/harness.ts
CHANGED
|
@@ -2286,7 +2286,12 @@ export class AgentHarness {
|
|
|
2286
2286
|
const modelName = agent.frontmatter.model?.name ?? "claude-opus-4-5";
|
|
2287
2287
|
const modelInstance = this.modelProvider(modelName);
|
|
2288
2288
|
const config = resolveCompactionConfig(agent.frontmatter.compaction);
|
|
2289
|
-
|
|
2289
|
+
const contextWindow =
|
|
2290
|
+
agent.frontmatter.model?.contextWindow ?? getModelContextWindow(modelName);
|
|
2291
|
+
return compactMessages(modelInstance, messages, config, {
|
|
2292
|
+
...options,
|
|
2293
|
+
contextWindow: options?.contextWindow ?? contextWindow,
|
|
2294
|
+
});
|
|
2290
2295
|
}
|
|
2291
2296
|
|
|
2292
2297
|
async *run(input: RunInput): AsyncGenerator<AgentEvent> {
|
|
@@ -2317,7 +2322,7 @@ export class AgentHarness {
|
|
|
2317
2322
|
let agent = this.parsedAgent as ParsedAgent;
|
|
2318
2323
|
const runId = `run_${randomUUID()}`;
|
|
2319
2324
|
const start = now();
|
|
2320
|
-
const maxSteps = agent.frontmatter.limits?.maxSteps ?? 20;
|
|
2325
|
+
const maxSteps = input.maxSteps ?? agent.frontmatter.limits?.maxSteps ?? 20;
|
|
2321
2326
|
// A constructor-level override (e.g. a longer budget for background
|
|
2322
2327
|
// subagents) takes precedence over the agent definition's limits.timeout.
|
|
2323
2328
|
const configuredTimeout = this.runTimeoutSecOverride ?? agent.frontmatter.limits?.timeout;
|
|
@@ -3082,6 +3087,7 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
|
|
|
3082
3087
|
modelInstance,
|
|
3083
3088
|
messages,
|
|
3084
3089
|
compactionConfig,
|
|
3090
|
+
{ contextWindow },
|
|
3085
3091
|
);
|
|
3086
3092
|
if (compactResult.compacted) {
|
|
3087
3093
|
messages.length = 0;
|
|
@@ -3096,6 +3102,16 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
|
|
|
3096
3102
|
emittedMessages.pop();
|
|
3097
3103
|
}
|
|
3098
3104
|
}
|
|
3105
|
+
// MID-RUN COMPACTION (step > 1) intentionally emits
|
|
3106
|
+
// `compactedMessages: undefined`. The live `messages` array is
|
|
3107
|
+
// already rewritten in place above (so the rest of the run and
|
|
3108
|
+
// the end-of-run `continuationMessages` reflect the compaction),
|
|
3109
|
+
// but we do NOT drive the runner's persisted `compactedHistory`
|
|
3110
|
+
// archive mid-turn: that would require a get→mutate→update on the
|
|
3111
|
+
// conversation row while the turn is still in flight, which is the
|
|
3112
|
+
// clobber pattern PonchOS forbids for non-terminal writers. The
|
|
3113
|
+
// compacted content is preserved in the summary; only the raw
|
|
3114
|
+
// pre-compaction transcript archive skips these mid-run events.
|
|
3099
3115
|
const tokensAfterCompaction = estimateTotalTokens(systemPrompt, messages, toolDefsJsonForEstimate);
|
|
3100
3116
|
latestContextTokens = tokensAfterCompaction;
|
|
3101
3117
|
toolOutputEstimateSinceModel = 0;
|
|
@@ -107,6 +107,48 @@ export const abnormalEndResponse = (opts: {
|
|
|
107
107
|
return opts.gathered ? `${head} ${recover}\n\n${opts.gathered}` : `${head} ${recover}`;
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Machine-readable verdict a subagent is asked to append to its final message,
|
|
112
|
+
* e.g. `[[OUTCOME: failed]] reason`. Parsed deterministically — see
|
|
113
|
+
* SUBAGENT_OUTCOME_INSTRUCTION and `deriveTaskOutcome`.
|
|
114
|
+
*/
|
|
115
|
+
const SUBAGENT_OUTCOME_RE = /\[\[\s*OUTCOME\s*:\s*(succeeded|failed|partial)\s*\]\]/i;
|
|
116
|
+
|
|
117
|
+
/** Appended to a subagent's task so it self-reports whether it succeeded. */
|
|
118
|
+
export const SUBAGENT_OUTCOME_INSTRUCTION =
|
|
119
|
+
"\n\nAt the very end of your final message, on its own line, append a " +
|
|
120
|
+
"machine-readable outcome verdict for the task you were given: " +
|
|
121
|
+
"`[[OUTCOME: succeeded]]`, `[[OUTCOME: partial]]`, or `[[OUTCOME: failed]]`, " +
|
|
122
|
+
"followed by a one-line reason. Judge honestly by whether YOU actually " +
|
|
123
|
+
"accomplished the task (produced the real result / wrote the files), not by " +
|
|
124
|
+
"whether the run finished. If a tool or capability you needed was missing, " +
|
|
125
|
+
"that is `failed`.";
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Derive a subagent's task outcome (distinct from run status). Abnormal ends and
|
|
129
|
+
* empty output are `failed`; otherwise we parse the self-declared verdict and
|
|
130
|
+
* default to `unknown` when absent (never assume success).
|
|
131
|
+
*/
|
|
132
|
+
export const deriveTaskOutcome = (
|
|
133
|
+
gathered: string,
|
|
134
|
+
abnormal: boolean,
|
|
135
|
+
): import("../state.js").SubagentTaskOutcome => {
|
|
136
|
+
if (abnormal) return "failed";
|
|
137
|
+
if (!gathered.trim()) return "failed";
|
|
138
|
+
const m = gathered.match(SUBAGENT_OUTCOME_RE);
|
|
139
|
+
if (!m) return "unknown";
|
|
140
|
+
const v = m[1]!.toLowerCase();
|
|
141
|
+
return v === "succeeded" ? "succeeded" : v === "partial" ? "partial" : "failed";
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Remove the machine-readable `[[OUTCOME: …]]` verdict (and its one-line reason)
|
|
146
|
+
* from the text delivered to the parent — the outcome is carried structurally in
|
|
147
|
+
* the callback header/metadata, so the marker shouldn't leak into chat UI.
|
|
148
|
+
*/
|
|
149
|
+
export const stripOutcomeVerdict = (text: string): string =>
|
|
150
|
+
text.replace(/\n*\[\[\s*OUTCOME\s*:\s*(?:succeeded|failed|partial)\s*\]\].*$/is, "").trimEnd();
|
|
151
|
+
|
|
110
152
|
// ── Types ──
|
|
111
153
|
|
|
112
154
|
export type ActiveConversationRun = {
|
|
@@ -697,6 +739,7 @@ export class AgentOrchestrator {
|
|
|
697
739
|
subagentId,
|
|
698
740
|
task: conv.subagentMeta?.task ?? conv.title,
|
|
699
741
|
status: "completed",
|
|
742
|
+
taskOutcome: deriveTaskOutcome(responseText, false),
|
|
700
743
|
result: { status: "completed", response: responseText, steps: 0, tokens: { input: 0, output: 0, cached: 0 }, duration: 0 },
|
|
701
744
|
timestamp: Date.now(),
|
|
702
745
|
};
|
|
@@ -829,7 +872,11 @@ export class AgentOrchestrator {
|
|
|
829
872
|
const recallParams = this.hooks?.buildRecallParams?.({ ownerId, tenantId: conversation.tenantId, excludeConversationId: childConversationId }) ?? {};
|
|
830
873
|
|
|
831
874
|
for await (const event of childHarness.runWithTelemetry({
|
|
832
|
-
task
|
|
875
|
+
// Ask the subagent to self-report a machine-readable task outcome at the
|
|
876
|
+
// end of its final message; `deriveTaskOutcome` parses it so a failed
|
|
877
|
+
// task can never be recorded as "completed". Appended only to the model
|
|
878
|
+
// input, not the stored/displayed task name.
|
|
879
|
+
task: `${task}${SUBAGENT_OUTCOME_INSTRUCTION}`,
|
|
833
880
|
conversationId: childConversationId,
|
|
834
881
|
tenantId: conversation.tenantId ?? undefined,
|
|
835
882
|
parameters: withToolResultArchiveParam({
|
|
@@ -1040,13 +1087,16 @@ export class AgentOrchestrator {
|
|
|
1040
1087
|
// the work — deliver what it gathered, tagged so the parent knows it
|
|
1041
1088
|
// didn't finish, and build a result so it never renders as "(no result)".
|
|
1042
1089
|
const abnormal = !runResult;
|
|
1090
|
+
const taskOutcome = deriveTaskOutcome(gathered, abnormal);
|
|
1091
|
+
const cleanedGathered = stripOutcomeVerdict(gathered);
|
|
1043
1092
|
const subagentResponse = abnormal
|
|
1044
|
-
? abnormalEndResponse({ subagentId: childConversationId, gathered, runError })
|
|
1045
|
-
:
|
|
1093
|
+
? abnormalEndResponse({ subagentId: childConversationId, gathered: cleanedGathered, runError })
|
|
1094
|
+
: cleanedGathered;
|
|
1046
1095
|
const pendingResult: PendingSubagentResult = {
|
|
1047
1096
|
subagentId: childConversationId,
|
|
1048
1097
|
task,
|
|
1049
1098
|
status: abnormal ? "error" : "completed",
|
|
1099
|
+
taskOutcome,
|
|
1050
1100
|
result: {
|
|
1051
1101
|
status: runResult?.status ?? "error",
|
|
1052
1102
|
response: subagentResponse,
|
|
@@ -1096,6 +1146,7 @@ export class AgentOrchestrator {
|
|
|
1096
1146
|
subagentId: childConversationId,
|
|
1097
1147
|
task,
|
|
1098
1148
|
status: "error",
|
|
1149
|
+
taskOutcome: "failed",
|
|
1099
1150
|
error: { code: "SUBAGENT_ERROR", message: errMsg },
|
|
1100
1151
|
timestamp: Date.now(),
|
|
1101
1152
|
};
|
|
@@ -1185,8 +1236,8 @@ export class AgentOrchestrator {
|
|
|
1185
1236
|
: "(no result)";
|
|
1186
1237
|
const injected: Message = {
|
|
1187
1238
|
role: "user",
|
|
1188
|
-
content: `[Subagent Result] Subagent "${pr.task}" (${pr.subagentId}) ${pr.
|
|
1189
|
-
metadata: { _subagentCallback: true, subagentId: pr.subagentId, task: pr.task, timestamp: pr.timestamp } as Message["metadata"],
|
|
1239
|
+
content: `[Subagent Result] Subagent "${pr.task}" (${pr.subagentId}) ${pr.taskOutcome}:\n\n${resultBody}`,
|
|
1240
|
+
metadata: { _subagentCallback: true, subagentId: pr.subagentId, task: pr.task, taskOutcome: pr.taskOutcome, timestamp: pr.timestamp } as Message["metadata"],
|
|
1190
1241
|
};
|
|
1191
1242
|
injectedCallbackMessages.push(injected);
|
|
1192
1243
|
conversation.messages.push(injected);
|
|
@@ -1516,9 +1567,11 @@ export class AgentOrchestrator {
|
|
|
1516
1567
|
if (freshSubConv) gathered = realResponseText(lastAssistantText(freshSubConv.messages));
|
|
1517
1568
|
}
|
|
1518
1569
|
const abnormal = !runResult;
|
|
1570
|
+
const taskOutcome = deriveTaskOutcome(gathered, abnormal);
|
|
1571
|
+
const cleanedGathered = stripOutcomeVerdict(gathered);
|
|
1519
1572
|
const subagentResponse = abnormal
|
|
1520
|
-
? abnormalEndResponse({ subagentId: conversationId, gathered, runError })
|
|
1521
|
-
:
|
|
1573
|
+
? abnormalEndResponse({ subagentId: conversationId, gathered: cleanedGathered, runError })
|
|
1574
|
+
: cleanedGathered;
|
|
1522
1575
|
|
|
1523
1576
|
const parentConv = await this.conversationStore.get(parentConversationId);
|
|
1524
1577
|
if (parentConv) {
|
|
@@ -1526,6 +1579,7 @@ export class AgentOrchestrator {
|
|
|
1526
1579
|
subagentId: conversationId,
|
|
1527
1580
|
task,
|
|
1528
1581
|
status: abnormal ? "error" : "completed",
|
|
1582
|
+
taskOutcome,
|
|
1529
1583
|
result: { status: runResult?.status ?? "error", response: subagentResponse, steps: runResult?.steps ?? 0, tokens: { input: 0, output: 0, cached: 0 }, duration: runResult?.duration ?? 0 },
|
|
1530
1584
|
...(abnormal
|
|
1531
1585
|
? { error: { code: runError?.code ?? "SUBAGENT_INCOMPLETE", message: runError?.message ?? "subagent ended without a result" } }
|
|
@@ -1576,6 +1630,7 @@ export class AgentOrchestrator {
|
|
|
1576
1630
|
subagentId: conversationId,
|
|
1577
1631
|
task,
|
|
1578
1632
|
status: "error",
|
|
1633
|
+
taskOutcome: "failed",
|
|
1579
1634
|
error: { code: "CONTINUATION_ERROR", message: err instanceof Error ? err.message : String(err) },
|
|
1580
1635
|
timestamp: Date.now(),
|
|
1581
1636
|
};
|
|
@@ -1858,6 +1913,7 @@ export class AgentOrchestrator {
|
|
|
1858
1913
|
subagentId: childConversationId,
|
|
1859
1914
|
task,
|
|
1860
1915
|
status: "error",
|
|
1916
|
+
taskOutcome: "failed",
|
|
1861
1917
|
error: { code: "SUBAGENT_SPAWN_FAILED", message },
|
|
1862
1918
|
timestamp: Date.now(),
|
|
1863
1919
|
});
|
|
@@ -1892,6 +1948,7 @@ export class AgentOrchestrator {
|
|
|
1892
1948
|
subagentId: conv.conversationId,
|
|
1893
1949
|
task: conv.subagentMeta.task,
|
|
1894
1950
|
status: "error",
|
|
1951
|
+
taskOutcome: "failed",
|
|
1895
1952
|
error: conv.subagentMeta.error,
|
|
1896
1953
|
timestamp: Date.now(),
|
|
1897
1954
|
};
|
|
@@ -84,6 +84,12 @@ export interface RunConversationTurnOpts {
|
|
|
84
84
|
* built with an OTLP exporter attached.
|
|
85
85
|
*/
|
|
86
86
|
suppressTelemetry?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Forwarded to `RunInput.maxSteps`: per-run step-ceiling override. Lets the
|
|
89
|
+
* foreground chat turn run a higher ceiling than background/job turns that
|
|
90
|
+
* share the same agent definition.
|
|
91
|
+
*/
|
|
92
|
+
maxSteps?: number;
|
|
87
93
|
/**
|
|
88
94
|
* Forwarded to `RunInput.telemetryAttributes` — extra attributes for the
|
|
89
95
|
* `invoke_agent` root span (e.g. run kind / job name for segmentation).
|
|
@@ -252,6 +258,7 @@ export const runConversationTurn = async (
|
|
|
252
258
|
disablePromptCache: opts.disablePromptCache,
|
|
253
259
|
suppressTelemetry: opts.suppressTelemetry,
|
|
254
260
|
model: opts.model,
|
|
261
|
+
maxSteps: opts.maxSteps,
|
|
255
262
|
volatileContext: opts.volatileContext,
|
|
256
263
|
telemetryAttributes: opts.telemetryAttributes,
|
|
257
264
|
},
|
package/src/state.ts
CHANGED
|
@@ -17,10 +17,23 @@ export interface StateStore {
|
|
|
17
17
|
delete(runId: string): Promise<void>;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Whether a subagent actually accomplished its task — distinct from the *run*
|
|
22
|
+
* status (`status` below), which is "completed" whenever the run merely
|
|
23
|
+
* finished. A subagent that ran to completion but could not do the task (e.g.
|
|
24
|
+
* lacked the required tools) is `runStatus:"completed"` but `taskOutcome:
|
|
25
|
+
* "failed"`. Derived at subagent finalize; "unknown" when it can't be
|
|
26
|
+
* determined. Consumed by the compaction subagent ledger so a failed task can
|
|
27
|
+
* never be summarized as "completed".
|
|
28
|
+
*/
|
|
29
|
+
export type SubagentTaskOutcome = "succeeded" | "failed" | "partial" | "unknown";
|
|
30
|
+
|
|
20
31
|
export interface PendingSubagentResult {
|
|
21
32
|
subagentId: string;
|
|
22
33
|
task: string;
|
|
23
34
|
status: "completed" | "error" | "stopped";
|
|
35
|
+
/** Did the subagent accomplish its task? See {@link SubagentTaskOutcome}. */
|
|
36
|
+
taskOutcome: SubagentTaskOutcome;
|
|
24
37
|
result?: import("@poncho-ai/sdk").RunResult;
|
|
25
38
|
error?: import("@poncho-ai/sdk").AgentFailure;
|
|
26
39
|
timestamp: number;
|
package/test/compaction.test.ts
CHANGED
|
@@ -5,8 +5,10 @@ import type { Message } from "@poncho-ai/sdk";
|
|
|
5
5
|
import {
|
|
6
6
|
compactMessages,
|
|
7
7
|
findSafeSplitPoint,
|
|
8
|
+
findSafeSplitPointByTurns,
|
|
8
9
|
resolveCompactionConfig,
|
|
9
10
|
} from "../src/compaction.js";
|
|
11
|
+
import { deriveTaskOutcome, stripOutcomeVerdict } from "../src/orchestrator/orchestrator.js";
|
|
10
12
|
|
|
11
13
|
// ── Fake model ──────────────────────────────────────────────────────────
|
|
12
14
|
// A MockLanguageModelV3 whose doGenerate returns a fixed text and records the
|
|
@@ -254,9 +256,9 @@ describe("compactMessages", () => {
|
|
|
254
256
|
expect(sentPrompt).toContain("MERGE AND UPDATE");
|
|
255
257
|
});
|
|
256
258
|
|
|
257
|
-
it("still truncates non-prior-summary long messages to
|
|
259
|
+
it("still truncates non-prior-summary long messages to the per-message cap", async () => {
|
|
258
260
|
const { model, prompts } = fakeModel("S");
|
|
259
|
-
const longUser = "X".repeat(
|
|
261
|
+
const longUser = "X".repeat(6000); // > SUMMARIZATION_MESSAGE_TRUNCATION_CHARS (4000)
|
|
260
262
|
const messages: Message[] = [
|
|
261
263
|
userMsg(longUser),
|
|
262
264
|
assistantText("a0"),
|
|
@@ -272,3 +274,123 @@ describe("compactMessages", () => {
|
|
|
272
274
|
expect(sentPrompt).not.toContain("MERGE AND UPDATE");
|
|
273
275
|
});
|
|
274
276
|
});
|
|
277
|
+
|
|
278
|
+
describe("findSafeSplitPointByTurns", () => {
|
|
279
|
+
const turns = (n: number, assistantText = "a"): Message[] => {
|
|
280
|
+
const msgs: Message[] = [];
|
|
281
|
+
for (let i = 0; i < n; i++) {
|
|
282
|
+
msgs.push(userMsg(`u${i}`));
|
|
283
|
+
msgs.push({ role: "assistant", content: `${assistantText}${i}` });
|
|
284
|
+
}
|
|
285
|
+
return msgs;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
it("preserves the last N whole turns verbatim", () => {
|
|
289
|
+
const messages = turns(6); // userIdx = [0,2,4,6,8,10]
|
|
290
|
+
// keepRecentTurns=4, generous token budget → split before the 4th-from-last
|
|
291
|
+
// user message (index 4), preserving turns u2..u5.
|
|
292
|
+
const idx = findSafeSplitPointByTurns(messages, 4, 6, 1_000_000);
|
|
293
|
+
expect(idx).toBe(4);
|
|
294
|
+
const preserved = messages.slice(idx);
|
|
295
|
+
const userCount = preserved.filter((m) => m.role === "user").length;
|
|
296
|
+
expect(userCount).toBe(4);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("reduces N when N turns exceed the preserved-token budget", () => {
|
|
300
|
+
// Each assistant turn is large (~460 tokens); 4 turns preserved would blow a
|
|
301
|
+
// small budget, so it must fall back to fewer turns.
|
|
302
|
+
const big = "Z".repeat(1600);
|
|
303
|
+
const messages = turns(6, big); // userIdx=[0,2,4,6,8,10]
|
|
304
|
+
const idx = findSafeSplitPointByTurns(messages, 4, 6, 1200);
|
|
305
|
+
// n=4 (~1840 tok) and n=3 (~1380 tok) exceed 1200; n=2 (~920 tok) fits →
|
|
306
|
+
// split at userIdx[len-2] = index 8, preserving 2 turns.
|
|
307
|
+
expect(idx).toBe(8);
|
|
308
|
+
expect(messages.slice(idx).filter((m) => m.role === "user").length).toBe(2);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("returns -1 for a single giant turn with no earlier user boundary", () => {
|
|
312
|
+
// One user message followed by many tool rounds — no safe boundary to
|
|
313
|
+
// compact against. (The message-based fallback also finds none.)
|
|
314
|
+
const messages: Message[] = [userMsg("do a big thing")];
|
|
315
|
+
for (let i = 0; i < 8; i++) {
|
|
316
|
+
messages.push(assistantToolCall(`step ${i}`, "run_code"));
|
|
317
|
+
messages.push(toolResult(`out ${i}`));
|
|
318
|
+
}
|
|
319
|
+
expect(findSafeSplitPointByTurns(messages, 4, 6, 1_000_000)).toBe(-1);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("deriveTaskOutcome / stripOutcomeVerdict", () => {
|
|
324
|
+
it("parses the self-declared verdict", () => {
|
|
325
|
+
expect(deriveTaskOutcome("did it [[OUTCOME: succeeded]] all good", false)).toBe("succeeded");
|
|
326
|
+
expect(deriveTaskOutcome("partial [[OUTCOME: partial]] some left", false)).toBe("partial");
|
|
327
|
+
expect(deriveTaskOutcome("couldn't [[OUTCOME: failed]] no tools", false)).toBe("failed");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("defaults to unknown when no verdict is present (never assumes success)", () => {
|
|
331
|
+
expect(deriveTaskOutcome("I finished everything.", false)).toBe("unknown");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("treats abnormal ends and empty output as failed", () => {
|
|
335
|
+
expect(deriveTaskOutcome("whatever", true)).toBe("failed");
|
|
336
|
+
expect(deriveTaskOutcome(" ", false)).toBe("failed");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("strips the verdict marker from delivered text", () => {
|
|
340
|
+
expect(stripOutcomeVerdict("Here is the report.\n[[OUTCOME: failed]] missing API key")).toBe(
|
|
341
|
+
"Here is the report.",
|
|
342
|
+
);
|
|
343
|
+
expect(stripOutcomeVerdict("No verdict here")).toBe("No verdict here");
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("compactMessages — failure fidelity", () => {
|
|
348
|
+
const config = resolveCompactionConfig({ keepRecentTurns: 1 });
|
|
349
|
+
|
|
350
|
+
it("renders a failed subagent as failed (never 'completed') and keeps its reason", async () => {
|
|
351
|
+
const { model } = fakeModel("SUMMARY");
|
|
352
|
+
const messages: Message[] = [
|
|
353
|
+
userMsg("please read my chats"),
|
|
354
|
+
assistantText("delegating to a subagent"),
|
|
355
|
+
userMsg(
|
|
356
|
+
'[Subagent Result] Subagent "read chats" (sub_fail) failed:\n\nI could not access the LinkedIn tools, so I read nothing.',
|
|
357
|
+
{
|
|
358
|
+
_subagentCallback: true,
|
|
359
|
+
subagentId: "sub_fail",
|
|
360
|
+
task: "read chats",
|
|
361
|
+
taskOutcome: "failed",
|
|
362
|
+
} as Message["metadata"],
|
|
363
|
+
),
|
|
364
|
+
assistantText("continuing"),
|
|
365
|
+
userMsg("how did it go?"),
|
|
366
|
+
assistantText("let me check"),
|
|
367
|
+
];
|
|
368
|
+
const res = await compactMessages(model, messages, config);
|
|
369
|
+
const content = res.messages[0]!.content as string;
|
|
370
|
+
expect(content).toContain("## Subagents");
|
|
371
|
+
expect(content).toContain("sub_fail");
|
|
372
|
+
// Rendered with the failed outcome, not "completed".
|
|
373
|
+
expect(content).toContain("— failed");
|
|
374
|
+
expect(content).not.toContain("(sub_fail) — completed");
|
|
375
|
+
// The failure reason survives verbatim.
|
|
376
|
+
expect(content).toContain("could not access the LinkedIn tools");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("keeps failure-bearing messages when the input budget forces drops", async () => {
|
|
380
|
+
const { model, prompts } = fakeModel("S");
|
|
381
|
+
// Many bulky non-failure messages plus one early failure. The failure must
|
|
382
|
+
// survive into the summarizer input even if older bulk is dropped.
|
|
383
|
+
const messages: Message[] = [userMsg("start")];
|
|
384
|
+
messages.push(toolResult("ERROR: the deploy failed because the token expired"));
|
|
385
|
+
for (let i = 0; i < 400; i++) {
|
|
386
|
+
messages.push(assistantText("filler ".repeat(200) + i));
|
|
387
|
+
messages.push(userMsg(`ok ${i}`));
|
|
388
|
+
}
|
|
389
|
+
messages.push(userMsg("final question"));
|
|
390
|
+
messages.push(assistantText("final answer"));
|
|
391
|
+
await compactMessages(model, messages, config);
|
|
392
|
+
const sentPrompt = prompts.join("\n");
|
|
393
|
+
// The failure line is marked important and never dropped.
|
|
394
|
+
expect(sentPrompt).toContain("the deploy failed because the token expired");
|
|
395
|
+
});
|
|
396
|
+
});
|