@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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/harness@0.60.1 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
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
  CLI tsup v8.5.1
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
- ESM dist/index.js 574.16 KB
12
11
  ESM dist/isolate-F2PPSUL6.js 53.82 KB
13
- ESM ⚡️ Build success in 245ms
12
+ ESM dist/index.js 579.17 KB
13
+ ESM ⚡️ Build success in 267ms
14
14
  DTS Build start
15
- DTS ⚡️ Build success in 7563ms
16
- DTS dist/index.d.ts 105.62 KB
15
+ DTS ⚡️ Build success in 8217ms
16
+ DTS dist/index.d.ts 108.62 KB
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
- keepRecentMessages: 4
363
+ keepRecentTurns: 4,
364
+ keepRecentMessages: 6
363
365
  };
364
- var SUMMARIZATION_MESSAGE_TRUNCATION_CHARS = 1200;
365
- var SUMMARIZATION_MAX_OUTPUT_TOKENS = 768;
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. **Errors & fixes**: Errors encountered and how they were resolved
372
- 5. **Referenced resources**: Files, URLs, tools, or data referenced
373
- 6. **Pending next steps**: What remains to be done
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 = 500;
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 conversationLines = [];
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
- conversationLines.push(`[${tag}]: ${rendered}`);
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] ?? "completed";
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 splitIdx = findSafeSplitPoint(messages, config.keepRecentMessages);
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
- return compactMessages(modelInstance, messages, config, options);
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 subagentResponse = abnormal ? abnormalEndResponse({ subagentId: childConversationId, gathered, runError }) : gathered;
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.status}:
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 subagentResponse = abnormal ? abnormalEndResponse({ subagentId: conversationId, gathered, runError }) : gathered;
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.60.1",
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.16.0"
37
+ "@poncho-ai/sdk": "1.17.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "esbuild": ">=0.17.0",
@@ -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
- keepRecentMessages: 4,
12
+ keepRecentTurns: 4,
13
+ keepRecentMessages: 6,
13
14
  };
14
- const SUMMARIZATION_MESSAGE_TRUNCATION_CHARS = 1200;
15
- const SUMMARIZATION_MAX_OUTPUT_TOKENS = 768;
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. **Errors & fixes**: Errors encountered and how they were resolved
23
- 5. **Referenced resources**: Files, URLs, tools, or data referenced
24
- 6. **Pending next steps**: What remains to be done
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
- /** Max chars of a subagent result text kept verbatim in the ledger digest. */
38
- const SUBAGENT_DIGEST_CHARS = 500;
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 1200-char truncation.
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
- const conversationLines: string[] = [];
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
- conversationLines.push(`[${tag}]: ${rendered}`);
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
- const status = headerMatch?.[3] ?? "completed";
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 splitIdx = findSafeSplitPoint(messages, config.keepRecentMessages);
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
- return compactMessages(modelInstance, messages, config, options);
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
- : gathered;
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.status}:\n\n${resultBody}`,
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
- : gathered;
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;
@@ -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 1200 chars", async () => {
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(3000);
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
+ });