@poncho-ai/harness 0.50.4 → 0.50.5

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.50.4 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.50.5 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 533.24 KB
11
+ ESM dist/index.js 535.57 KB
12
12
  ESM dist/isolate-F2PPSUL6.js 53.82 KB
13
- ESM ⚡️ Build success in 250ms
13
+ ESM ⚡️ Build success in 240ms
14
14
  DTS Build start
15
- DTS ⚡️ Build success in 9179ms
16
- DTS dist/index.d.ts 89.97 KB
15
+ DTS ⚡️ Build success in 7598ms
16
+ DTS dist/index.d.ts 91.35 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.50.5
4
+
5
+ ### Patch Changes
6
+
7
+ - [`991a4b9`](https://github.com/cesr/poncho-ai/commit/991a4b98d6683c105c7aae50551d30b16080d618) Thanks [@cesr](https://github.com/cesr)! - harness: subagents survive the wall-clock timeout, and can be given a longer budget than the foreground turn.
8
+
9
+ Previously a subagent that hit its hard `timeout` (vs. `maxSteps`) emitted `run:error` with no `runResult`, so the orchestrator dropped everything it had gathered: the parent received a bare `(no result)`, the subagent was falsely marked `completed`, and the work — often dozens of completed searches, just short of the write step — was lost.
10
+ - **Graceful timeout/error delivery.** When a subagent run ends abnormally (timeout or model error) with no `runResult`, the orchestrator now recovers its real output (run response → streamed draft → transcript walk-back, discarding the synthetic `[Error: …]` placeholder), and delivers it tagged so the parent knows it didn't finish — it may not have written its files — with a concrete recovery hint (use the partial work, send a write-only `message_subagent` follow-up, or `read_subagent(…, mode:"full")`). The subagent is marked `status: "error"` (not a fake `completed`) and carries the failure in its `error` field. Applied to both the spawn and continuation paths.
11
+ - **`runTimeoutSecOverride` (HarnessOptions).** A constructor-level override for the per-run hard wall-clock timeout, taking precedence over the agent definition's `limits.timeout`. Lets a platform give background subagents a longer budget (e.g. 1h) than a foreground turn (5 min) without forking the agent definition. `0` disables the hard timeout.
12
+
3
13
  ## 0.50.4
4
14
 
5
15
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -1261,6 +1261,14 @@ interface HarnessOptions {
1261
1261
  * should also be browsable in the VFS. Empty by default.
1262
1262
  */
1263
1263
  systemSkillPaths?: string[];
1264
+ /**
1265
+ * Override the per-run hard wall-clock timeout, in seconds, taking
1266
+ * precedence over the agent definition's `limits.timeout`. Platforms use
1267
+ * this to give background subagents a longer budget than the foreground
1268
+ * agent without forking the agent definition (e.g. a 1h research subagent
1269
+ * vs. a 5-min foreground turn). `0` disables the hard timeout.
1270
+ */
1271
+ runTimeoutSecOverride?: number;
1264
1272
  }
1265
1273
  interface HarnessRunOutput {
1266
1274
  runId: string;
@@ -1280,6 +1288,7 @@ interface ArchivedToolResult {
1280
1288
  declare class AgentHarness {
1281
1289
  private readonly workingDir;
1282
1290
  private readonly environment;
1291
+ private readonly runTimeoutSecOverride?;
1283
1292
  private modelProvider;
1284
1293
  private readonly modelProviderInjected;
1285
1294
  private readonly dispatcher;
@@ -1988,6 +1997,28 @@ declare const STALE_SUBAGENT_THRESHOLD_MS: number;
1988
1997
  * before — instead of surfacing to the parent as an empty result.
1989
1998
  */
1990
1999
  declare const lastAssistantText: (messages: Message[]) => string;
2000
+ /**
2001
+ * The run loop stuffs a synthetic `[Error: ...]` placeholder into the draft /
2002
+ * persisted assistant text when a run ends on `run:error` (e.g. a timeout).
2003
+ * That placeholder is not real model output — strip it so we don't surface it
2004
+ * to the parent as the subagent's "response".
2005
+ */
2006
+ declare const realResponseText: (text: string | undefined) => string;
2007
+ /**
2008
+ * Build the result text delivered to the parent when a subagent ended
2009
+ * abnormally (timeout / error) with no RunResult. We never drop the work it
2010
+ * gathered, and the parent is told it didn't finish — e.g. it may not have
2011
+ * written its output files — plus how to recover (use what's here, send a
2012
+ * write-only follow-up, or read the full transcript).
2013
+ */
2014
+ declare const abnormalEndResponse: (opts: {
2015
+ subagentId: string;
2016
+ gathered: string;
2017
+ runError?: {
2018
+ code?: string;
2019
+ message?: string;
2020
+ };
2021
+ }) => string;
1991
2022
  type ActiveConversationRun = {
1992
2023
  ownerId: string;
1993
2024
  abortController: AbortController;
@@ -2150,4 +2181,4 @@ interface RunConversationTurnResult {
2150
2181
  }
2151
2182
  declare const runConversationTurn: (opts: RunConversationTurnOpts) => Promise<RunConversationTurnResult>;
2152
2183
 
2153
- export { type ActiveConversationRun, type ActiveSubagentRun, type AgentFrontmatter, AgentHarness, type AgentIdentity, type AgentLimitsConfig, type AgentModelConfig, AgentOrchestrator, type ApprovalEventItem, type ArchivedToolResult$1 as ArchivedToolResult, type BashConfig, BashEnvironmentManager, type BashExecutionLimits, type BuiltInToolToggles, CALLBACK_LOCK_STALE_MS, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type ContinuationHooks, type Conversation, type ConversationCreateInit, type ConversationState, type ConversationStatusSnapshot, type ConversationStore, type ConversationSummary, type CreateSkillToolsOptions, type CronJobConfig, DEFAULT_AGENT_DESCRIPTION, DEFAULT_AGENT_NAME, DEFAULT_MAX_STEPS, DEFAULT_MODEL_NAME, DEFAULT_MODEL_PROVIDER, 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, OPENAI_CODEX_CLIENT_ID, type OpenAICodexAuthConfig, type OpenAICodexDeviceAuthRequest, type OpenAICodexSession, type OrchestratorHooks, type OrchestratorOptions, type OtlpConfig, type OtlpOption, PONCHO_UPLOAD_SCHEME, type ParsedAgent, type PendingSubagentApproval, type PendingSubagentResult, type PendingToolCall, type PonchoConfig, PonchoFsAdapter, PostgresEngine, type ProviderConfig, type Recurrence, type RecurrenceType, type Reminder, type ReminderCreateInput, type ReminderStatus, type ReminderStore, type RemoteMcpServerConfig, type RunConversationTurnOpts, type RunConversationTurnResult, type RunOutcome, type RunRequest, type RuntimeRenderContext, S3UploadStore, STALE_SUBAGENT_THRESHOLD_MS, STORAGE_SCHEMA_VERSION, type SecretsStore, type SkillContextEntry, type SkillMetadata, type SkillSource, SqliteEngine, type StateConfig, type StateProviderName, type StateStore, type StorageConfig, type StorageEngine, type StorageFactoryOptions, type StorageProvider, type StoredApproval, type SubagentManager, type SubagentResult, type SubagentSpawnResult, type SubagentSummary, 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, 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, getPonchoStoreRoot, isMessageArray, jsonSchemaToZod, lastAssistantText, loadCanonicalHistory, loadPonchoConfig, loadRunHistory, loadSkillContext, loadSkillInstructions, loadSkillMetadata, loadSkillMetadataFromDirs, loadVfsSkillMetadata, mergeSkills, normalizeApprovalCheckpoint, normalizeOtlp, normalizeScriptPolicyPath, normalizeToolAccess, parseAgentFile, parseAgentMarkdown, parseSkillFrontmatter, ponchoDocsTool, readOpenAICodexSession, readSkillResource, recordStandardTurnEvent, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveEnv, resolveMemoryConfig, resolveRunRequest, resolveSkillDirs, resolveStateConfig, runConversationTurn, slugifyStorageComponent, startOpenAICodexDeviceAuth, verifyTenantToken, withToolResultArchiveParam, writeOpenAICodexSession };
2184
+ export { type ActiveConversationRun, type ActiveSubagentRun, type AgentFrontmatter, AgentHarness, type AgentIdentity, type AgentLimitsConfig, type AgentModelConfig, AgentOrchestrator, type ApprovalEventItem, type ArchivedToolResult$1 as ArchivedToolResult, type BashConfig, BashEnvironmentManager, type BashExecutionLimits, type BuiltInToolToggles, CALLBACK_LOCK_STALE_MS, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type ContinuationHooks, type Conversation, type ConversationCreateInit, type ConversationState, type ConversationStatusSnapshot, type ConversationStore, type ConversationSummary, type CreateSkillToolsOptions, type CronJobConfig, DEFAULT_AGENT_DESCRIPTION, DEFAULT_AGENT_NAME, DEFAULT_MAX_STEPS, DEFAULT_MODEL_NAME, DEFAULT_MODEL_PROVIDER, 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, OPENAI_CODEX_CLIENT_ID, type OpenAICodexAuthConfig, type OpenAICodexDeviceAuthRequest, type OpenAICodexSession, type OrchestratorHooks, type OrchestratorOptions, type OtlpConfig, type OtlpOption, PONCHO_UPLOAD_SCHEME, type ParsedAgent, type PendingSubagentApproval, type PendingSubagentResult, type PendingToolCall, type PonchoConfig, PonchoFsAdapter, PostgresEngine, type ProviderConfig, type Recurrence, type RecurrenceType, type Reminder, type ReminderCreateInput, type ReminderStatus, type ReminderStore, type RemoteMcpServerConfig, type RunConversationTurnOpts, type RunConversationTurnResult, type RunOutcome, type RunRequest, type RuntimeRenderContext, S3UploadStore, STALE_SUBAGENT_THRESHOLD_MS, STORAGE_SCHEMA_VERSION, type SecretsStore, type SkillContextEntry, type SkillMetadata, type SkillSource, SqliteEngine, type StateConfig, type StateProviderName, type StateStore, type StorageConfig, type StorageEngine, type StorageFactoryOptions, type StorageProvider, type StoredApproval, type SubagentManager, type SubagentResult, type SubagentSpawnResult, type SubagentSummary, 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, 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, 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, verifyTenantToken, withToolResultArchiveParam, writeOpenAICodexSession };
package/dist/index.js CHANGED
@@ -9170,6 +9170,7 @@ function extractMediaFromToolOutput(output) {
9170
9170
  var AgentHarness = class _AgentHarness {
9171
9171
  workingDir;
9172
9172
  environment;
9173
+ runTimeoutSecOverride;
9173
9174
  modelProvider;
9174
9175
  modelProviderInjected;
9175
9176
  dispatcher = new ToolDispatcher();
@@ -9375,6 +9376,7 @@ var AgentHarness = class _AgentHarness {
9375
9376
  constructor(options = {}) {
9376
9377
  this.workingDir = options.workingDir ?? process.cwd();
9377
9378
  this.environment = options.environment ?? "development";
9379
+ this.runTimeoutSecOverride = options.runTimeoutSecOverride;
9378
9380
  this.modelProviderInjected = !!options.modelProvider;
9379
9381
  this.modelProvider = options.modelProvider ?? createModelProvider("anthropic");
9380
9382
  this.uploadStore = options.uploadStore;
@@ -10237,7 +10239,7 @@ var AgentHarness = class _AgentHarness {
10237
10239
  const runId = `run_${randomUUID5()}`;
10238
10240
  const start = now();
10239
10241
  const maxSteps = agent.frontmatter.limits?.maxSteps ?? 20;
10240
- const configuredTimeout = agent.frontmatter.limits?.timeout;
10242
+ const configuredTimeout = this.runTimeoutSecOverride ?? agent.frontmatter.limits?.timeout;
10241
10243
  const timeoutMs = this.environment === "development" && configuredTimeout == null ? 0 : (configuredTimeout ?? 300) * 1e3;
10242
10244
  const platformMaxDurationSec = Number(process.env.PONCHO_MAX_DURATION) || 0;
10243
10245
  const softDeadlineMs = input.disableSoftDeadline || platformMaxDurationSec <= 0 ? 0 : platformMaxDurationSec * 800;
@@ -12340,6 +12342,18 @@ var lastAssistantText = (messages) => {
12340
12342
  }
12341
12343
  return "";
12342
12344
  };
12345
+ var realResponseText = (text) => {
12346
+ const t = (text ?? "").trim();
12347
+ return t.startsWith("[Error:") ? "" : t;
12348
+ };
12349
+ var abnormalEndResponse = (opts) => {
12350
+ const timedOut = opts.runError?.code === "TIMEOUT";
12351
+ const head = timedOut ? "[Subagent hit its time limit before finishing \u2014 it may not have written its output files.]" : `[Subagent ended before finishing${opts.runError?.message ? `: ${opts.runError.message}` : ""}.]`;
12352
+ const recover = opts.gathered ? "Partial work it gathered is below \u2014 write the files yourself from it, or send a tight write-only follow-up with message_subagent." : `Use read_subagent("${opts.subagentId}", mode:"full") to recover what it gathered.`;
12353
+ return opts.gathered ? `${head} ${recover}
12354
+
12355
+ ${opts.gathered}` : `${head} ${recover}`;
12356
+ };
12343
12357
  var AgentOrchestrator = class {
12344
12358
  harness;
12345
12359
  conversationStore;
@@ -12865,6 +12879,7 @@ var AgentOrchestrator = class {
12865
12879
  const draft = createTurnDraftState();
12866
12880
  let latestRunId = "";
12867
12881
  let runResult;
12882
+ let runError;
12868
12883
  try {
12869
12884
  const conversation = await this.conversationStore.getWithArchive(childConversationId);
12870
12885
  if (!conversation) throw new Error("Subagent conversation not found");
@@ -13001,6 +13016,7 @@ var AgentOrchestrator = class {
13001
13016
  }
13002
13017
  }
13003
13018
  if (event.type === "run:error") {
13019
+ runError = { code: event.error.code, message: event.error.message };
13004
13020
  draft.assistantResponse = draft.assistantResponse || `[Error: ${event.error.message}]`;
13005
13021
  }
13006
13022
  await this.eventSink(childConversationId, event);
@@ -13048,7 +13064,12 @@ var AgentOrchestrator = class {
13048
13064
  }
13049
13065
  return;
13050
13066
  }
13051
- conv.subagentMeta = { ...conv.subagentMeta, status: "completed" };
13067
+ const abnormalEnd = !runResult;
13068
+ conv.subagentMeta = {
13069
+ ...conv.subagentMeta,
13070
+ status: abnormalEnd ? "error" : "completed",
13071
+ ...abnormalEnd ? { error: { code: runError?.code ?? "SUBAGENT_INCOMPLETE", message: runError?.message ?? "subagent ended without a result" } } : {}
13072
+ };
13052
13073
  await this.conversationStore.update(conv);
13053
13074
  }
13054
13075
  this.hooks?.onStreamEnd?.(childConversationId);
@@ -13057,18 +13078,25 @@ var AgentOrchestrator = class {
13057
13078
  subagentId: childConversationId,
13058
13079
  conversationId: childConversationId
13059
13080
  });
13060
- let subagentResponse = (runResult?.response ?? draft.assistantResponse ?? "").trim();
13061
- if (!subagentResponse) {
13081
+ let gathered = realResponseText(runResult?.response) || realResponseText(draft.assistantResponse);
13082
+ if (!gathered) {
13062
13083
  const freshSubConv = await this.conversationStore.get(childConversationId);
13063
- if (freshSubConv) {
13064
- subagentResponse = lastAssistantText(freshSubConv.messages);
13065
- }
13084
+ if (freshSubConv) gathered = realResponseText(lastAssistantText(freshSubConv.messages));
13066
13085
  }
13086
+ const abnormal = !runResult;
13087
+ const subagentResponse = abnormal ? abnormalEndResponse({ subagentId: childConversationId, gathered, runError }) : gathered;
13067
13088
  const pendingResult = {
13068
13089
  subagentId: childConversationId,
13069
13090
  task,
13070
- status: "completed",
13071
- result: runResult ? { status: runResult.status, response: subagentResponse, steps: runResult.steps, tokens: { input: 0, output: 0, cached: 0 }, duration: runResult.duration } : void 0,
13091
+ status: abnormal ? "error" : "completed",
13092
+ result: {
13093
+ status: runResult?.status ?? "error",
13094
+ response: subagentResponse,
13095
+ steps: runResult?.steps ?? 0,
13096
+ tokens: { input: 0, output: 0, cached: 0 },
13097
+ duration: runResult?.duration ?? 0
13098
+ },
13099
+ ...abnormal ? { error: { code: runError?.code ?? "SUBAGENT_INCOMPLETE", message: runError?.message ?? "subagent ended without a result" } } : {},
13072
13100
  timestamp: Date.now()
13073
13101
  };
13074
13102
  await this.conversationStore.appendSubagentResult(parentConversationId, pendingResult);
@@ -13316,6 +13344,7 @@ ${resultBody}`,
13316
13344
  this.activeSubagentRuns.set(conversationId, { abortController: childAbortController, harness: childHarness, parentConversationId });
13317
13345
  const draft = createTurnDraftState();
13318
13346
  let runResult;
13347
+ let runError;
13319
13348
  try {
13320
13349
  const recallParams = this.hooks?.buildRecallParams?.({ ownerId, tenantId: conversation.tenantId, excludeConversationId: conversationId }) ?? {};
13321
13350
  for await (const event of childHarness.runWithTelemetry({
@@ -13348,6 +13377,7 @@ ${resultBody}`,
13348
13377
  }
13349
13378
  }
13350
13379
  if (event.type === "run:error") {
13380
+ runError = { code: event.error.code, message: event.error.message };
13351
13381
  draft.assistantResponse = draft.assistantResponse || `[Error: ${event.error.message}]`;
13352
13382
  }
13353
13383
  await this.eventSink(conversationId, event);
@@ -13396,7 +13426,12 @@ ${resultBody}`,
13396
13426
  }
13397
13427
  return;
13398
13428
  }
13399
- conv.subagentMeta = { ...conv.subagentMeta, status: "completed" };
13429
+ const abnormalEnd = !runResult;
13430
+ conv.subagentMeta = {
13431
+ ...conv.subagentMeta,
13432
+ status: abnormalEnd ? "error" : "completed",
13433
+ ...abnormalEnd ? { error: { code: runError?.code ?? "SUBAGENT_INCOMPLETE", message: runError?.message ?? "subagent ended without a result" } } : {}
13434
+ };
13400
13435
  await this.conversationStore.update(conv);
13401
13436
  }
13402
13437
  this.activeSubagentRuns.delete(conversationId);
@@ -13405,20 +13440,21 @@ ${resultBody}`,
13405
13440
  subagentId: conversationId,
13406
13441
  conversationId
13407
13442
  });
13408
- let subagentResponse = (runResult?.response ?? draft.assistantResponse ?? "").trim();
13409
- if (!subagentResponse) {
13443
+ let gathered = realResponseText(runResult?.response) || realResponseText(draft.assistantResponse);
13444
+ if (!gathered) {
13410
13445
  const freshSubConv = await this.conversationStore.get(conversationId);
13411
- if (freshSubConv) {
13412
- subagentResponse = lastAssistantText(freshSubConv.messages);
13413
- }
13446
+ if (freshSubConv) gathered = realResponseText(lastAssistantText(freshSubConv.messages));
13414
13447
  }
13448
+ const abnormal = !runResult;
13449
+ const subagentResponse = abnormal ? abnormalEndResponse({ subagentId: conversationId, gathered, runError }) : gathered;
13415
13450
  const parentConv = await this.conversationStore.get(parentConversationId);
13416
13451
  if (parentConv) {
13417
13452
  const result = {
13418
13453
  subagentId: conversationId,
13419
13454
  task,
13420
- status: "completed",
13421
- result: { status: "completed", response: subagentResponse, steps: runResult?.steps ?? 0, tokens: { input: 0, output: 0, cached: 0 }, duration: runResult?.duration ?? 0 },
13455
+ status: abnormal ? "error" : "completed",
13456
+ result: { status: runResult?.status ?? "error", response: subagentResponse, steps: runResult?.steps ?? 0, tokens: { input: 0, output: 0, cached: 0 }, duration: runResult?.duration ?? 0 },
13457
+ ...abnormal ? { error: { code: runError?.code ?? "SUBAGENT_INCOMPLETE", message: runError?.message ?? "subagent ended without a result" } } : {},
13422
13458
  timestamp: Date.now()
13423
13459
  };
13424
13460
  await this.conversationStore.appendSubagentResult(parentConversationId, result);
@@ -14048,6 +14084,7 @@ export {
14048
14084
  ToolDispatcher,
14049
14085
  VFS_SCHEME,
14050
14086
  VercelBlobUploadStore,
14087
+ abnormalEndResponse,
14051
14088
  applyTurnMetadata,
14052
14089
  buildAgentDirectoryName,
14053
14090
  buildApprovalCheckpoints,
@@ -14122,6 +14159,7 @@ export {
14122
14159
  ponchoDocsTool,
14123
14160
  readOpenAICodexSession,
14124
14161
  readSkillResource,
14162
+ realResponseText,
14125
14163
  recordStandardTurnEvent,
14126
14164
  renderAgentPrompt,
14127
14165
  resolveAgentIdentity,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.50.4",
3
+ "version": "0.50.5",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/harness.ts CHANGED
@@ -146,6 +146,14 @@ export interface HarnessOptions {
146
146
  * should also be browsable in the VFS. Empty by default.
147
147
  */
148
148
  systemSkillPaths?: string[];
149
+ /**
150
+ * Override the per-run hard wall-clock timeout, in seconds, taking
151
+ * precedence over the agent definition's `limits.timeout`. Platforms use
152
+ * this to give background subagents a longer budget than the foreground
153
+ * agent without forking the agent definition (e.g. a 1h research subagent
154
+ * vs. a 5-min foreground turn). `0` disables the hard timeout.
155
+ */
156
+ runTimeoutSecOverride?: number;
149
157
  }
150
158
 
151
159
  export interface HarnessRunOutput {
@@ -848,6 +856,7 @@ function extractMediaFromToolOutput(output: unknown): {
848
856
  export class AgentHarness {
849
857
  private readonly workingDir: string;
850
858
  private readonly environment: HarnessOptions["environment"];
859
+ private readonly runTimeoutSecOverride?: number;
851
860
  private modelProvider: ModelProviderFactory;
852
861
  private readonly modelProviderInjected: boolean;
853
862
  private readonly dispatcher = new ToolDispatcher();
@@ -1084,6 +1093,7 @@ export class AgentHarness {
1084
1093
  constructor(options: HarnessOptions = {}) {
1085
1094
  this.workingDir = options.workingDir ?? process.cwd();
1086
1095
  this.environment = options.environment ?? "development";
1096
+ this.runTimeoutSecOverride = options.runTimeoutSecOverride;
1087
1097
  this.modelProviderInjected = !!options.modelProvider;
1088
1098
  this.modelProvider = options.modelProvider ?? createModelProvider("anthropic");
1089
1099
  this.uploadStore = options.uploadStore;
@@ -2126,7 +2136,9 @@ export class AgentHarness {
2126
2136
  const runId = `run_${randomUUID()}`;
2127
2137
  const start = now();
2128
2138
  const maxSteps = agent.frontmatter.limits?.maxSteps ?? 20;
2129
- const configuredTimeout = agent.frontmatter.limits?.timeout;
2139
+ // A constructor-level override (e.g. a longer budget for background
2140
+ // subagents) takes precedence over the agent definition's limits.timeout.
2141
+ const configuredTimeout = this.runTimeoutSecOverride ?? agent.frontmatter.limits?.timeout;
2130
2142
  const timeoutMs = this.environment === "development" && configuredTimeout == null
2131
2143
  ? 0 // no hard timeout in development unless explicitly configured
2132
2144
  : (configuredTimeout ?? 300) * 1000;
@@ -47,6 +47,8 @@ export {
47
47
  export {
48
48
  AgentOrchestrator,
49
49
  lastAssistantText,
50
+ realResponseText,
51
+ abnormalEndResponse,
50
52
  type ActiveConversationRun,
51
53
  type EventSink,
52
54
  type OrchestratorHooks,
@@ -67,6 +67,39 @@ export const lastAssistantText = (messages: Message[]): string => {
67
67
  return "";
68
68
  };
69
69
 
70
+ /**
71
+ * The run loop stuffs a synthetic `[Error: ...]` placeholder into the draft /
72
+ * persisted assistant text when a run ends on `run:error` (e.g. a timeout).
73
+ * That placeholder is not real model output — strip it so we don't surface it
74
+ * to the parent as the subagent's "response".
75
+ */
76
+ export const realResponseText = (text: string | undefined): string => {
77
+ const t = (text ?? "").trim();
78
+ return t.startsWith("[Error:") ? "" : t;
79
+ };
80
+
81
+ /**
82
+ * Build the result text delivered to the parent when a subagent ended
83
+ * abnormally (timeout / error) with no RunResult. We never drop the work it
84
+ * gathered, and the parent is told it didn't finish — e.g. it may not have
85
+ * written its output files — plus how to recover (use what's here, send a
86
+ * write-only follow-up, or read the full transcript).
87
+ */
88
+ export const abnormalEndResponse = (opts: {
89
+ subagentId: string;
90
+ gathered: string;
91
+ runError?: { code?: string; message?: string };
92
+ }): string => {
93
+ const timedOut = opts.runError?.code === "TIMEOUT";
94
+ const head = timedOut
95
+ ? "[Subagent hit its time limit before finishing — it may not have written its output files.]"
96
+ : `[Subagent ended before finishing${opts.runError?.message ? `: ${opts.runError.message}` : ""}.]`;
97
+ const recover = opts.gathered
98
+ ? "Partial work it gathered is below — write the files yourself from it, or send a tight write-only follow-up with message_subagent."
99
+ : `Use read_subagent("${opts.subagentId}", mode:"full") to recover what it gathered.`;
100
+ return opts.gathered ? `${head} ${recover}\n\n${opts.gathered}` : `${head} ${recover}`;
101
+ };
102
+
70
103
  // ── Types ──
71
104
 
72
105
  export type ActiveConversationRun = {
@@ -762,6 +795,7 @@ export class AgentOrchestrator {
762
795
  const draft = createTurnDraftState();
763
796
  let latestRunId = "";
764
797
  let runResult: { status: "completed" | "error" | "cancelled"; response?: string; steps: number; duration: number; continuation?: boolean; continuationMessages?: Message[] } | undefined;
798
+ let runError: { code?: string; message?: string } | undefined;
765
799
 
766
800
  try {
767
801
  const conversation = await this.conversationStore.getWithArchive(childConversationId);
@@ -911,6 +945,7 @@ export class AgentOrchestrator {
911
945
  }
912
946
  }
913
947
  if (event.type === "run:error") {
948
+ runError = { code: event.error.code, message: event.error.message };
914
949
  draft.assistantResponse = draft.assistantResponse || `[Error: ${event.error.message}]`;
915
950
  }
916
951
  await this.eventSink(childConversationId, event);
@@ -961,7 +996,17 @@ export class AgentOrchestrator {
961
996
  return;
962
997
  }
963
998
 
964
- conv.subagentMeta = { ...conv.subagentMeta!, status: "completed" };
999
+ // No runResult means the run ended on run:error (timeout / model
1000
+ // error) rather than run:completed — flag the subagent accordingly
1001
+ // instead of faking "completed".
1002
+ const abnormalEnd = !runResult;
1003
+ conv.subagentMeta = {
1004
+ ...conv.subagentMeta!,
1005
+ status: abnormalEnd ? "error" : "completed",
1006
+ ...(abnormalEnd
1007
+ ? { error: { code: runError?.code ?? "SUBAGENT_INCOMPLETE", message: runError?.message ?? "subagent ended without a result" } }
1008
+ : {}),
1009
+ };
965
1010
  await this.conversationStore.update(conv);
966
1011
  }
967
1012
 
@@ -972,18 +1017,36 @@ export class AgentOrchestrator {
972
1017
  conversationId: childConversationId,
973
1018
  });
974
1019
 
975
- let subagentResponse = (runResult?.response ?? draft.assistantResponse ?? "").trim();
976
- if (!subagentResponse) {
1020
+ // Recover the subagent's real output: prefer the run response, then the
1021
+ // streamed draft, then walk the transcript — discarding the synthetic
1022
+ // "[Error: ...]" placeholder at each step.
1023
+ let gathered = realResponseText(runResult?.response) || realResponseText(draft.assistantResponse);
1024
+ if (!gathered) {
977
1025
  const freshSubConv = await this.conversationStore.get(childConversationId);
978
- if (freshSubConv) {
979
- subagentResponse = lastAssistantText(freshSubConv.messages);
980
- }
1026
+ if (freshSubConv) gathered = realResponseText(lastAssistantText(freshSubConv.messages));
981
1027
  }
1028
+
1029
+ // On an abnormal end (timeout / error) there is no runResult; don't drop
1030
+ // the work — deliver what it gathered, tagged so the parent knows it
1031
+ // didn't finish, and build a result so it never renders as "(no result)".
1032
+ const abnormal = !runResult;
1033
+ const subagentResponse = abnormal
1034
+ ? abnormalEndResponse({ subagentId: childConversationId, gathered, runError })
1035
+ : gathered;
982
1036
  const pendingResult: PendingSubagentResult = {
983
1037
  subagentId: childConversationId,
984
1038
  task,
985
- status: "completed",
986
- result: runResult ? { status: runResult.status, response: subagentResponse, steps: runResult.steps, tokens: { input: 0, output: 0, cached: 0 }, duration: runResult.duration } : undefined,
1039
+ status: abnormal ? "error" : "completed",
1040
+ result: {
1041
+ status: runResult?.status ?? "error",
1042
+ response: subagentResponse,
1043
+ steps: runResult?.steps ?? 0,
1044
+ tokens: { input: 0, output: 0, cached: 0 },
1045
+ duration: runResult?.duration ?? 0,
1046
+ },
1047
+ ...(abnormal
1048
+ ? { error: { code: runError?.code ?? "SUBAGENT_INCOMPLETE", message: runError?.message ?? "subagent ended without a result" } }
1049
+ : {}),
987
1050
  timestamp: Date.now(),
988
1051
  };
989
1052
  await this.conversationStore.appendSubagentResult(parentConversationId, pendingResult);
@@ -1271,7 +1334,8 @@ export class AgentOrchestrator {
1271
1334
  this.activeSubagentRuns.set(conversationId, { abortController: childAbortController, harness: childHarness, parentConversationId });
1272
1335
 
1273
1336
  const draft = createTurnDraftState();
1274
- let runResult: { status: string; response?: string; steps: number; duration: number; continuation?: boolean; continuationMessages?: Message[] } | undefined;
1337
+ let runResult: { status: "completed" | "error" | "cancelled"; response?: string; steps: number; duration: number; continuation?: boolean; continuationMessages?: Message[] } | undefined;
1338
+ let runError: { code?: string; message?: string } | undefined;
1275
1339
 
1276
1340
  try {
1277
1341
  const recallParams = this.hooks?.buildRecallParams?.({ ownerId, tenantId: conversation.tenantId, excludeConversationId: conversationId }) ?? {};
@@ -1306,6 +1370,7 @@ export class AgentOrchestrator {
1306
1370
  }
1307
1371
  }
1308
1372
  if (event.type === "run:error") {
1373
+ runError = { code: event.error.code, message: event.error.message };
1309
1374
  draft.assistantResponse = draft.assistantResponse || `[Error: ${event.error.message}]`;
1310
1375
  }
1311
1376
  await this.eventSink(conversationId, event);
@@ -1355,7 +1420,14 @@ export class AgentOrchestrator {
1355
1420
  return;
1356
1421
  }
1357
1422
 
1358
- conv.subagentMeta = { ...conv.subagentMeta!, status: "completed" };
1423
+ const abnormalEnd = !runResult;
1424
+ conv.subagentMeta = {
1425
+ ...conv.subagentMeta!,
1426
+ status: abnormalEnd ? "error" : "completed",
1427
+ ...(abnormalEnd
1428
+ ? { error: { code: runError?.code ?? "SUBAGENT_INCOMPLETE", message: runError?.message ?? "subagent ended without a result" } }
1429
+ : {}),
1430
+ };
1359
1431
  await this.conversationStore.update(conv);
1360
1432
  }
1361
1433
 
@@ -1366,21 +1438,26 @@ export class AgentOrchestrator {
1366
1438
  conversationId,
1367
1439
  });
1368
1440
 
1369
- let subagentResponse = (runResult?.response ?? draft.assistantResponse ?? "").trim();
1370
- if (!subagentResponse) {
1441
+ let gathered = realResponseText(runResult?.response) || realResponseText(draft.assistantResponse);
1442
+ if (!gathered) {
1371
1443
  const freshSubConv = await this.conversationStore.get(conversationId);
1372
- if (freshSubConv) {
1373
- subagentResponse = lastAssistantText(freshSubConv.messages);
1374
- }
1444
+ if (freshSubConv) gathered = realResponseText(lastAssistantText(freshSubConv.messages));
1375
1445
  }
1446
+ const abnormal = !runResult;
1447
+ const subagentResponse = abnormal
1448
+ ? abnormalEndResponse({ subagentId: conversationId, gathered, runError })
1449
+ : gathered;
1376
1450
 
1377
1451
  const parentConv = await this.conversationStore.get(parentConversationId);
1378
1452
  if (parentConv) {
1379
1453
  const result: PendingSubagentResult = {
1380
1454
  subagentId: conversationId,
1381
1455
  task,
1382
- status: "completed",
1383
- result: { status: "completed", response: subagentResponse, steps: runResult?.steps ?? 0, tokens: { input: 0, output: 0, cached: 0 }, duration: runResult?.duration ?? 0 },
1456
+ status: abnormal ? "error" : "completed",
1457
+ result: { status: runResult?.status ?? "error", response: subagentResponse, steps: runResult?.steps ?? 0, tokens: { input: 0, output: 0, cached: 0 }, duration: runResult?.duration ?? 0 },
1458
+ ...(abnormal
1459
+ ? { error: { code: runError?.code ?? "SUBAGENT_INCOMPLETE", message: runError?.message ?? "subagent ended without a result" } }
1460
+ : {}),
1384
1461
  timestamp: Date.now(),
1385
1462
  };
1386
1463
  await this.conversationStore.appendSubagentResult(parentConversationId, result);
@@ -9,6 +9,8 @@ import {
9
9
  recordStandardTurnEvent,
10
10
  executeConversationTurn,
11
11
  lastAssistantText,
12
+ realResponseText,
13
+ abnormalEndResponse,
12
14
  } from "../src/orchestrator/index.js";
13
15
  import type { Conversation } from "../src/state.js";
14
16
 
@@ -237,3 +239,50 @@ describe("lastAssistantText (subagent result extraction)", () => {
237
239
  expect(lastAssistantText(messages)).toBe("");
238
240
  });
239
241
  });
242
+
243
+ describe("realResponseText (strips run:error placeholder)", () => {
244
+ it("drops the synthetic [Error: ...] placeholder", () => {
245
+ expect(realResponseText("[Error: Run exceeded timeout of 300s]")).toBe("");
246
+ });
247
+ it("keeps real text and trims", () => {
248
+ expect(realResponseText(" done, wrote the file ")).toBe("done, wrote the file");
249
+ });
250
+ it("handles undefined", () => {
251
+ expect(realResponseText(undefined)).toBe("");
252
+ });
253
+ });
254
+
255
+ describe("abnormalEndResponse (graceful timeout / error delivery)", () => {
256
+ it("timeout WITH gathered work: notes the cutoff and includes the work", () => {
257
+ const out = abnormalEndResponse({
258
+ subagentId: "sub_1",
259
+ gathered: "Found 12 competitors: A, B, C...",
260
+ runError: { code: "TIMEOUT", message: "Run exceeded timeout of 3600s" },
261
+ });
262
+ expect(out).toContain("time limit");
263
+ expect(out).toContain("may not have written its output files");
264
+ expect(out).toContain("Found 12 competitors: A, B, C...");
265
+ expect(out).not.toContain("(no result)");
266
+ });
267
+
268
+ it("timeout WITHOUT gathered work: points at read_subagent to recover", () => {
269
+ const out = abnormalEndResponse({
270
+ subagentId: "sub_2",
271
+ gathered: "",
272
+ runError: { code: "TIMEOUT", message: "Run exceeded timeout of 3600s" },
273
+ });
274
+ expect(out).toContain("time limit");
275
+ expect(out).toContain('read_subagent("sub_2"');
276
+ expect(out).toContain('mode:"full"');
277
+ });
278
+
279
+ it("non-timeout error: surfaces the error message", () => {
280
+ const out = abnormalEndResponse({
281
+ subagentId: "sub_3",
282
+ gathered: "",
283
+ runError: { code: "EMPTY_RESPONSE", message: "model returned no content" },
284
+ });
285
+ expect(out).toContain("ended before finishing");
286
+ expect(out).toContain("model returned no content");
287
+ });
288
+ });