bopodev-agent-sdk 0.1.24 → 0.1.26

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.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-lint.log +4 -0
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/agent-sdk/src/adapters.d.ts +12 -1
  5. package/dist/agent-sdk/src/provider-failures/anthropic-api.d.ts +5 -0
  6. package/dist/agent-sdk/src/provider-failures/claude-code.d.ts +5 -0
  7. package/dist/agent-sdk/src/provider-failures/codex.d.ts +5 -0
  8. package/dist/agent-sdk/src/provider-failures/common.d.ts +7 -0
  9. package/dist/agent-sdk/src/provider-failures/cursor.d.ts +5 -0
  10. package/dist/agent-sdk/src/provider-failures/gemini-cli.d.ts +5 -0
  11. package/dist/agent-sdk/src/provider-failures/http.d.ts +5 -0
  12. package/dist/agent-sdk/src/provider-failures/index.d.ts +5 -0
  13. package/dist/agent-sdk/src/provider-failures/openai-api.d.ts +5 -0
  14. package/dist/agent-sdk/src/provider-failures/opencode.d.ts +5 -0
  15. package/dist/agent-sdk/src/provider-failures/shell.d.ts +5 -0
  16. package/dist/agent-sdk/src/provider-failures/types.d.ts +20 -0
  17. package/dist/agent-sdk/src/runtime-core.d.ts +1 -1
  18. package/dist/agent-sdk/src/runtime-http.d.ts +4 -2
  19. package/dist/agent-sdk/src/runtime-parsers.d.ts +1 -1
  20. package/dist/agent-sdk/src/runtime.d.ts +13 -0
  21. package/dist/agent-sdk/src/types.d.ts +17 -1
  22. package/dist/contracts/src/index.d.ts +1134 -2
  23. package/package.json +2 -2
  24. package/src/adapters.ts +426 -52
  25. package/src/provider-failures/anthropic-api.ts +20 -0
  26. package/src/provider-failures/claude-code.ts +20 -0
  27. package/src/provider-failures/codex.ts +23 -0
  28. package/src/provider-failures/common.ts +86 -0
  29. package/src/provider-failures/cursor.ts +20 -0
  30. package/src/provider-failures/gemini-cli.ts +20 -0
  31. package/src/provider-failures/http.ts +12 -0
  32. package/src/provider-failures/index.ts +54 -0
  33. package/src/provider-failures/openai-api.ts +20 -0
  34. package/src/provider-failures/opencode.ts +20 -0
  35. package/src/provider-failures/shell.ts +12 -0
  36. package/src/provider-failures/types.ts +28 -0
  37. package/src/runtime-core.ts +7 -1
  38. package/src/runtime-http.ts +51 -6
  39. package/src/runtime-parsers.ts +1 -0
  40. package/src/runtime.ts +283 -0
  41. package/src/types.ts +17 -1
package/src/adapters.ts CHANGED
@@ -11,7 +11,12 @@ import type {
11
11
  HeartbeatContext
12
12
  } from "./types";
13
13
  import { ExecutionOutcomeSchema, type ExecutionOutcome } from "bopodev-contracts";
14
- import { checkRuntimeCommandHealth, containsRateLimitFailure, executeAgentRuntime, executePromptRuntime } from "./runtime-core";
14
+ import {
15
+ checkRuntimeCommandHealth,
16
+ containsUsageLimitHardStopFailure,
17
+ executeAgentRuntime,
18
+ executePromptRuntime
19
+ } from "./runtime-core";
15
20
  import {
16
21
  parseClaudeStreamOutput,
17
22
  parseCursorStreamOutput,
@@ -24,6 +29,10 @@ import {
24
29
  resolveDirectApiCredentials,
25
30
  type DirectApiProvider
26
31
  } from "./runtime-http";
32
+ import {
33
+ classifyProviderFailure as classifyProviderFailureByProvider,
34
+ normalizeProviderFailureDetail as normalizeProviderFailureDetailByProvider
35
+ } from "./provider-failures";
27
36
  import { homedir } from "node:os";
28
37
  import { basename, join, resolve } from "node:path";
29
38
 
@@ -42,8 +51,39 @@ function toOutcome(outcome: ExecutionOutcome): ExecutionOutcome {
42
51
  return ExecutionOutcomeSchema.parse(outcome);
43
52
  }
44
53
 
45
- function isRateLimitedRuntimeFailure(runtime: { stdout: string; stderr: string }, detail?: string) {
46
- return containsRateLimitFailure(`${detail ?? ""}\n${runtime.stderr}\n${runtime.stdout}`);
54
+ function isProviderUsageLimitedRuntimeFailure(runtime: { stdout: string; stderr: string }, detail?: string) {
55
+ return containsUsageLimitHardStopFailure(`${detail ?? ""}\n${runtime.stderr}\n${runtime.stdout}`);
56
+ }
57
+
58
+ function buildProviderUsageLimitedDispositionHint(
59
+ provider: string,
60
+ detail: string
61
+ ): NonNullable<AdapterExecutionResult["dispositionHint"]> {
62
+ const normalizedDetail = detail.replace(/\s+/g, " ").trim();
63
+ const message = normalizedDetail ? `${provider} usage limit reached: ${normalizedDetail}` : `${provider} usage limit reached.`;
64
+ return {
65
+ kind: "provider_usage_limited",
66
+ persistStatus: "skipped",
67
+ pauseAgent: true,
68
+ notifyBoard: true,
69
+ message
70
+ };
71
+ }
72
+
73
+ export function normalizeProviderFailureDetail(provider: AgentProviderType, detail: string) {
74
+ return normalizeProviderFailureDetailByProvider(provider, detail);
75
+ }
76
+
77
+ export function classifyProviderFailure(
78
+ provider: AgentProviderType,
79
+ input: {
80
+ detail: string;
81
+ stderr?: string;
82
+ stdout?: string;
83
+ failureType?: string | null;
84
+ }
85
+ ): ReturnType<typeof classifyProviderFailureByProvider> {
86
+ return classifyProviderFailureByProvider(provider, input);
47
87
  }
48
88
 
49
89
  type RuntimeParsedUsage = {
@@ -130,6 +170,53 @@ function usageTokenInputTotal(usage: RuntimeParsedUsage | undefined) {
130
170
  return Math.max(0, usage.tokenInput ?? 0);
131
171
  }
132
172
 
173
+ function resolveFinalRunOutputContractDetail(input: {
174
+ provider: string;
175
+ runtime: {
176
+ structuredOutputDiagnostics?: {
177
+ finalRunOutputError?: string;
178
+ };
179
+ };
180
+ }) {
181
+ const detail = input.runtime.structuredOutputDiagnostics?.finalRunOutputError?.trim();
182
+ return detail || `${input.provider} runtime did not return a valid final JSON object.`;
183
+ }
184
+
185
+ function createContractInvalidResult(input: {
186
+ context: HeartbeatContext;
187
+ provider: AgentProviderType;
188
+ summary: string;
189
+ tokenInput: number;
190
+ tokenOutput: number;
191
+ usdCost: number;
192
+ usage?: AdapterNormalizedUsage;
193
+ pricingProviderType?: string | null;
194
+ pricingModelId?: string | null;
195
+ trace: NonNullable<AdapterExecutionResult["trace"]>;
196
+ nextState: HeartbeatContext["state"];
197
+ }): AdapterExecutionResult {
198
+ return {
199
+ status: "failed",
200
+ summary: input.summary,
201
+ tokenInput: input.tokenInput,
202
+ tokenOutput: input.tokenOutput,
203
+ usdCost: input.usdCost,
204
+ ...(input.usage ? { usage: input.usage } : {}),
205
+ pricingProviderType: input.pricingProviderType,
206
+ pricingModelId: input.pricingModelId,
207
+ outcome: toOutcome({
208
+ kind: "failed",
209
+ issueIdsTouched: issueIdsTouched(input.context),
210
+ actions: [{ type: "runtime.contract", status: "error", detail: input.summary }],
211
+ blockers: [{ code: "contract_invalid", message: input.summary, retryable: true }],
212
+ artifacts: [],
213
+ nextSuggestedState: "blocked"
214
+ }),
215
+ trace: input.trace,
216
+ nextState: input.nextState
217
+ };
218
+ }
219
+
133
220
  function hasUsageMetrics(usage: RuntimeParsedUsage | undefined) {
134
221
  if (!usage) {
135
222
  return false;
@@ -372,12 +459,46 @@ export class GenericHeartbeatAdapter implements AgentAdapter {
372
459
  nextState: context.state
373
460
  };
374
461
  }
462
+ if (!runtime.finalRunOutput) {
463
+ const usage = toNormalizedUsage(runtime.parsedUsage);
464
+ const detail = resolveFinalRunOutputContractDetail({ provider: this.providerType, runtime });
465
+ return createContractInvalidResult({
466
+ context,
467
+ provider: this.providerType,
468
+ summary: `${this.providerType} runtime failed contract validation: ${detail}`,
469
+ tokenInput: usageTokenInputTotal(runtime.parsedUsage),
470
+ tokenOutput: runtime.parsedUsage?.tokenOutput ?? 0,
471
+ usdCost: runtime.parsedUsage?.usdCost ?? 0,
472
+ ...(usage ? { usage } : {}),
473
+ pricingProviderType: resolveCanonicalPricingProviderKey(this.providerType),
474
+ pricingModelId: context.runtime?.model?.trim() || null,
475
+ trace: {
476
+ command: runtime.commandUsed ?? context.runtime.command,
477
+ args: runtime.argsUsed,
478
+ cwd: context.runtime?.cwd,
479
+ exitCode: runtime.code,
480
+ elapsedMs: runtime.elapsedMs,
481
+ timedOut: runtime.timedOut,
482
+ failureType: "contract_invalid",
483
+ timeoutSource: runtime.timedOut ? "runtime" : null,
484
+ attemptCount: runtime.attemptCount,
485
+ attempts: runtime.attempts,
486
+ structuredOutputSource: runtime.structuredOutputSource,
487
+ structuredOutputDiagnostics: runtime.structuredOutputDiagnostics,
488
+ stdoutPreview: toPreview(runtime.stdout),
489
+ stderrPreview: toPreview(runtime.stderr),
490
+ transcript: runtime.transcript
491
+ },
492
+ nextState: context.state
493
+ });
494
+ }
375
495
  return {
376
496
  status: "ok",
377
497
  summary: runtime.parsedUsage?.summary ?? `${this.providerType} runtime finished in ${runtime.elapsedMs}ms.`,
378
498
  tokenInput: runtime.parsedUsage?.tokenInput ?? 0,
379
499
  tokenOutput: runtime.parsedUsage?.tokenOutput ?? 0,
380
500
  usdCost: runtime.parsedUsage?.usdCost ?? 0,
501
+ finalRunOutput: runtime.finalRunOutput,
381
502
  outcome: toOutcome({
382
503
  kind: "completed",
383
504
  issueIdsTouched: issueIdsTouched(context),
@@ -409,23 +530,27 @@ export class GenericHeartbeatAdapter implements AgentAdapter {
409
530
  }
410
531
 
411
532
  const failedUsage = resolveFailedUsage(runtime);
412
- const failureDetail = resolveRuntimeFailureDetail(runtime);
413
- const rateLimitedFailure = isRateLimitedRuntimeFailure(runtime, failureDetail);
533
+ const failure = classifyProviderFailure(this.providerType, {
534
+ detail: resolveRuntimeFailureDetail(runtime, this.providerType),
535
+ stdout: runtime.stdout,
536
+ stderr: runtime.stderr,
537
+ failureType: runtime.failureType
538
+ });
414
539
  return {
415
540
  status: "failed",
416
- summary: runtime.parsedUsage?.summary ?? `${this.providerType} runtime failed: ${failureDetail}`,
541
+ summary: runtime.parsedUsage?.summary ?? `${this.providerType} runtime failed: ${failure.detail}`,
417
542
  tokenInput: failedUsage.tokenInput,
418
543
  tokenOutput: failedUsage.tokenOutput,
419
544
  usdCost: failedUsage.usdCost,
420
545
  outcome: toOutcome({
421
546
  kind: "failed",
422
547
  issueIdsTouched: issueIdsTouched(context),
423
- actions: [{ type: "runtime.execute", status: "error", detail: failureDetail }],
548
+ actions: [{ type: "runtime.execute", status: "error", detail: failure.detail }],
424
549
  blockers: [
425
550
  {
426
- code: runtime.failureType ?? "runtime_failed",
427
- message: failureDetail,
428
- retryable: !rateLimitedFailure
551
+ code: failure.blockerCode,
552
+ message: failure.detail,
553
+ retryable: failure.retryable
429
554
  }
430
555
  ],
431
556
  artifacts: [],
@@ -449,6 +574,9 @@ export class GenericHeartbeatAdapter implements AgentAdapter {
449
574
  stderrPreview: toPreview(runtime.stderr),
450
575
  transcript: runtime.transcript
451
576
  },
577
+ ...(failure.providerUsageLimited
578
+ ? { dispositionHint: buildProviderUsageLimitedDispositionHint(this.providerType, failure.detail) }
579
+ : {}),
452
580
  nextState: context.state
453
581
  };
454
582
  }
@@ -838,12 +966,51 @@ export async function runDirectApiWork(
838
966
  const prompt = createPrompt(context);
839
967
  const runtime = await executeDirectApiRuntime(provider, prompt, context.runtime);
840
968
  if (runtime.ok) {
969
+ if (!runtime.finalRunOutput) {
970
+ return createContractInvalidResult({
971
+ context,
972
+ provider,
973
+ summary: `${provider} runtime failed contract validation: ${runtime.summary ?? "Missing final JSON object."}`,
974
+ tokenInput: runtime.tokenInput ?? 0,
975
+ tokenOutput: runtime.tokenOutput ?? 0,
976
+ usdCost: runtime.usdCost ?? 0,
977
+ usage: {
978
+ inputTokens: runtime.tokenInput ?? 0,
979
+ cachedInputTokens: 0,
980
+ outputTokens: runtime.tokenOutput ?? 0,
981
+ ...(runtime.usdCost !== undefined ? { costUsd: runtime.usdCost } : {}),
982
+ ...(runtime.summary ? { summary: runtime.summary } : {})
983
+ },
984
+ pricingProviderType: runtime.provider,
985
+ pricingModelId: runtime.model,
986
+ trace: {
987
+ command: runtime.endpoint,
988
+ cwd: context.runtime?.cwd,
989
+ exitCode: runtime.statusCode,
990
+ elapsedMs: runtime.elapsedMs,
991
+ failureType: "contract_invalid",
992
+ usageSource: "structured",
993
+ attemptCount: runtime.attemptCount,
994
+ attempts: runtime.attempts.map((attempt) => ({
995
+ attempt: attempt.attempt,
996
+ code: attempt.statusCode || null,
997
+ timedOut: attempt.failureType === "timeout",
998
+ elapsedMs: attempt.elapsedMs,
999
+ signal: null,
1000
+ forcedKill: false
1001
+ })),
1002
+ stdoutPreview: runtime.responsePreview
1003
+ },
1004
+ nextState: context.state
1005
+ });
1006
+ }
841
1007
  return {
842
1008
  status: "ok",
843
1009
  summary: runtime.summary ?? `${provider} runtime finished in ${runtime.elapsedMs}ms.`,
844
1010
  tokenInput: runtime.tokenInput ?? 0,
845
1011
  tokenOutput: runtime.tokenOutput ?? 0,
846
1012
  usdCost: runtime.usdCost ?? 0,
1013
+ finalRunOutput: runtime.finalRunOutput,
847
1014
  pricingProviderType: runtime.provider,
848
1015
  pricingModelId: runtime.model,
849
1016
  outcome: toOutcome({
@@ -875,12 +1042,15 @@ export async function runDirectApiWork(
875
1042
  nextState: withProviderMetadata(context, provider, runtime.elapsedMs, runtime.statusCode)
876
1043
  };
877
1044
  }
878
- const failureDetail = runtime.error ?? "direct API request failed";
879
- const rateLimitedFailure =
880
- runtime.failureType === "rate_limit" || containsRateLimitFailure(`${failureDetail}\n${runtime.responsePreview ?? ""}`);
1045
+ const failure = classifyProviderFailure(provider, {
1046
+ detail: runtime.error ?? "direct API request failed",
1047
+ stderr: runtime.error,
1048
+ stdout: runtime.responsePreview ?? "",
1049
+ failureType: runtime.failureType
1050
+ });
881
1051
  return {
882
1052
  status: "failed",
883
- summary: `${provider} runtime failed: ${failureDetail}`,
1053
+ summary: `${provider} runtime failed: ${failure.detail}`,
884
1054
  tokenInput: 0,
885
1055
  tokenOutput: 0,
886
1056
  usdCost: 0,
@@ -889,11 +1059,11 @@ export async function runDirectApiWork(
889
1059
  outcome: toOutcome({
890
1060
  kind: "failed",
891
1061
  issueIdsTouched: issueIdsTouched(context),
892
- actions: [{ type: "runtime.execute", status: "error", detail: failureDetail }],
1062
+ actions: [{ type: "runtime.execute", status: "error", detail: failure.detail }],
893
1063
  blockers: [{
894
- code: runtime.failureType ?? "runtime_failed",
895
- message: failureDetail,
896
- retryable: runtime.failureType !== "auth" && runtime.failureType !== "bad_response" && !rateLimitedFailure
1064
+ code: failure.blockerCode,
1065
+ message: failure.detail,
1066
+ retryable: failure.retryable
897
1067
  }],
898
1068
  artifacts: [],
899
1069
  nextSuggestedState: "blocked"
@@ -917,6 +1087,9 @@ export async function runDirectApiWork(
917
1087
  stderrPreview: runtime.error,
918
1088
  stdoutPreview: runtime.responsePreview
919
1089
  },
1090
+ ...(failure.providerUsageLimited
1091
+ ? { dispositionHint: buildProviderUsageLimitedDispositionHint(provider, failure.detail) }
1092
+ : {}),
920
1093
  nextState: context.state
921
1094
  };
922
1095
  }
@@ -1042,6 +1215,40 @@ export async function runProviderWork(
1042
1215
  nextState: context.state
1043
1216
  };
1044
1217
  }
1218
+ if (!runtime.finalRunOutput) {
1219
+ const usage = toNormalizedUsage(runtime.parsedUsage);
1220
+ const detail = resolveFinalRunOutputContractDetail({ provider, runtime });
1221
+ return createContractInvalidResult({
1222
+ context,
1223
+ provider,
1224
+ summary: `${provider} runtime failed contract validation: ${detail}`,
1225
+ tokenInput: usageTokenInputTotal(runtime.parsedUsage),
1226
+ tokenOutput: runtime.parsedUsage?.outputTokens ?? runtime.parsedUsage?.tokenOutput ?? 0,
1227
+ usdCost: runtime.parsedUsage?.costUsd ?? runtime.parsedUsage?.usdCost ?? 0,
1228
+ ...(usage ? { usage } : {}),
1229
+ pricingProviderType,
1230
+ pricingModelId,
1231
+ trace: {
1232
+ command: runtime.commandUsed ?? context.runtime?.command ?? provider,
1233
+ args: runtime.argsUsed,
1234
+ cwd: context.runtime?.cwd,
1235
+ exitCode: runtime.code,
1236
+ elapsedMs: runtime.elapsedMs,
1237
+ timedOut: runtime.timedOut,
1238
+ failureType: "contract_invalid",
1239
+ timeoutSource: runtime.timedOut ? "runtime" : null,
1240
+ usageSource: "structured",
1241
+ attemptCount: runtime.attemptCount,
1242
+ attempts: runtime.attempts,
1243
+ structuredOutputSource: runtime.structuredOutputSource,
1244
+ structuredOutputDiagnostics: runtime.structuredOutputDiagnostics,
1245
+ stdoutPreview: toPreview(runtime.stdout),
1246
+ stderrPreview: toPreview(runtime.stderr),
1247
+ transcript: runtime.transcript
1248
+ },
1249
+ nextState: context.state
1250
+ });
1251
+ }
1045
1252
  if (provider === "claude_code" && isClaudeRunIncomplete(runtime)) {
1046
1253
  const detail = "Claude run reached max-turns before completing execution for this issue.";
1047
1254
  const usage = toNormalizedUsage(runtime.parsedUsage);
@@ -1095,6 +1302,7 @@ export async function runProviderWork(
1095
1302
  tokenInput,
1096
1303
  tokenOutput,
1097
1304
  usdCost,
1305
+ finalRunOutput: runtime.finalRunOutput,
1098
1306
  usage,
1099
1307
  pricingProviderType,
1100
1308
  pricingModelId,
@@ -1128,11 +1336,15 @@ export async function runProviderWork(
1128
1336
  };
1129
1337
  }
1130
1338
  const failedUsage = resolveFailedUsage(runtime);
1131
- const failureDetail = resolveRuntimeFailureDetail(runtime);
1132
- const rateLimitedFailure = isRateLimitedRuntimeFailure(runtime, failureDetail);
1339
+ const failure = classifyProviderFailure(provider, {
1340
+ detail: resolveRuntimeFailureDetail(runtime, provider),
1341
+ stdout: runtime.stdout,
1342
+ stderr: runtime.stderr,
1343
+ failureType: runtime.failureType
1344
+ });
1133
1345
  return {
1134
1346
  status: "failed",
1135
- summary: runtime.parsedUsage?.summary ?? `${provider} runtime failed: ${failureDetail}`,
1347
+ summary: runtime.parsedUsage?.summary ?? `${provider} runtime failed: ${failure.detail}`,
1136
1348
  tokenInput: failedUsage.tokenInput,
1137
1349
  tokenOutput: failedUsage.tokenOutput,
1138
1350
  usdCost: failedUsage.usdCost,
@@ -1142,12 +1354,12 @@ export async function runProviderWork(
1142
1354
  outcome: toOutcome({
1143
1355
  kind: "failed",
1144
1356
  issueIdsTouched: issueIdsTouched(context),
1145
- actions: [{ type: "runtime.execute", status: "error", detail: failureDetail }],
1357
+ actions: [{ type: "runtime.execute", status: "error", detail: failure.detail }],
1146
1358
  blockers: [
1147
1359
  {
1148
- code: runtime.failureType ?? "runtime_failed",
1149
- message: failureDetail,
1150
- retryable: !rateLimitedFailure
1360
+ code: failure.blockerCode,
1361
+ message: failure.detail,
1362
+ retryable: failure.retryable
1151
1363
  }
1152
1364
  ],
1153
1365
  artifacts: [],
@@ -1171,6 +1383,9 @@ export async function runProviderWork(
1171
1383
  stderrPreview: toPreview(runtime.stderr),
1172
1384
  transcript: runtime.transcript
1173
1385
  },
1386
+ ...(failure.providerUsageLimited
1387
+ ? { dispositionHint: buildProviderUsageLimitedDispositionHint(provider, failure.detail) }
1388
+ : {}),
1174
1389
  nextState: context.state
1175
1390
  };
1176
1391
  }
@@ -1220,7 +1435,7 @@ export async function runCursorWork(
1220
1435
  if (
1221
1436
  !runtime.ok &&
1222
1437
  resumeState.resumeSessionId &&
1223
- !isRateLimitedRuntimeFailure(runtime) &&
1438
+ !isProviderUsageLimitedRuntimeFailure(runtime) &&
1224
1439
  isUnknownSessionError(runtime.stderr, runtime.stdout)
1225
1440
  ) {
1226
1441
  const retry = withResolvedRuntimeUsage(
@@ -1331,7 +1546,12 @@ export async function runOpenCodeWork(context: HeartbeatContext): Promise<Adapte
1331
1546
  { provider: "opencode" }
1332
1547
  );
1333
1548
  const parsed = parseOpenCodeOutput(runtime.stdout);
1334
- if (!runtime.ok && resumeSessionId && !isRateLimitedRuntimeFailure(runtime) && isUnknownSessionError(runtime.stderr, runtime.stdout)) {
1549
+ if (
1550
+ !runtime.ok &&
1551
+ resumeSessionId &&
1552
+ !isProviderUsageLimitedRuntimeFailure(runtime) &&
1553
+ isUnknownSessionError(runtime.stderr, runtime.stdout)
1554
+ ) {
1335
1555
  const retry = await executePromptRuntime(
1336
1556
  context.runtime?.command ?? "opencode",
1337
1557
  prompt,
@@ -1426,7 +1646,7 @@ export async function runGeminiCliWork(
1426
1646
  if (
1427
1647
  !runtime.ok &&
1428
1648
  resumeState.resumeSessionId &&
1429
- !isRateLimitedRuntimeFailure(runtime) &&
1649
+ !isProviderUsageLimitedRuntimeFailure(runtime) &&
1430
1650
  isGeminiUnknownSessionError(runtime.stdout, runtime.stderr)
1431
1651
  ) {
1432
1652
  const retry = withResolvedRuntimeUsage(
@@ -1535,6 +1755,7 @@ export function toProviderResult(
1535
1755
  forcedKill: boolean;
1536
1756
  }>;
1537
1757
  parsedUsage?: RuntimeParsedUsage;
1758
+ finalRunOutput?: AdapterExecutionResult["finalRunOutput"];
1538
1759
  structuredOutputSource?: "stdout" | "stderr";
1539
1760
  structuredOutputDiagnostics?: {
1540
1761
  stdoutJsonObjectCount: number;
@@ -1550,6 +1771,8 @@ export function toProviderResult(
1550
1771
  | "json_missing"
1551
1772
  | "json_on_stderr_only"
1552
1773
  | "schema_or_shape_mismatch";
1774
+ finalRunOutputStatus?: "valid" | "missing" | "malformed" | "schema_mismatch";
1775
+ finalRunOutputError?: string;
1553
1776
  claudeStopReason?: string;
1554
1777
  claudeResultSubtype?: string;
1555
1778
  claudeSessionId?: string;
@@ -1626,6 +1849,41 @@ export function toProviderResult(
1626
1849
  nextState: applyProviderSessionState(context, provider, sessionUpdate)
1627
1850
  };
1628
1851
  }
1852
+ if (!runtime.finalRunOutput) {
1853
+ const usage = toNormalizedUsage(runtime.parsedUsage);
1854
+ const detail = resolveFinalRunOutputContractDetail({ provider, runtime });
1855
+ return createContractInvalidResult({
1856
+ context,
1857
+ provider,
1858
+ summary: `${provider} runtime failed contract validation: ${detail}`,
1859
+ tokenInput: usageTokenInputTotal(runtime.parsedUsage),
1860
+ tokenOutput: runtime.parsedUsage?.outputTokens ?? runtime.parsedUsage?.tokenOutput ?? 0,
1861
+ usdCost: runtime.parsedUsage?.costUsd ?? runtime.parsedUsage?.usdCost ?? 0,
1862
+ ...(usage ? { usage } : {}),
1863
+ pricingProviderType,
1864
+ pricingModelId,
1865
+ trace: {
1866
+ command: runtime.commandUsed ?? context.runtime?.command ?? provider,
1867
+ args: runtime.argsUsed,
1868
+ cwd: context.runtime?.cwd,
1869
+ exitCode: runtime.code,
1870
+ elapsedMs: runtime.elapsedMs,
1871
+ timedOut: runtime.timedOut,
1872
+ failureType: "contract_invalid",
1873
+ timeoutSource: runtime.timedOut ? "runtime" : null,
1874
+ usageSource: "structured",
1875
+ attemptCount: runtime.attemptCount,
1876
+ attempts: runtime.attempts,
1877
+ session: sessionUpdate,
1878
+ structuredOutputSource: runtime.structuredOutputSource,
1879
+ structuredOutputDiagnostics: runtime.structuredOutputDiagnostics,
1880
+ stdoutPreview: toPreview(runtime.stdout),
1881
+ stderrPreview: toPreview(runtime.stderr),
1882
+ transcript: runtime.transcript
1883
+ },
1884
+ nextState: applyProviderSessionState(context, provider, sessionUpdate)
1885
+ });
1886
+ }
1629
1887
  const tokenOutput = runtime.parsedUsage?.outputTokens ?? runtime.parsedUsage?.tokenOutput ?? 0;
1630
1888
  const usdCost = runtime.parsedUsage?.costUsd ?? runtime.parsedUsage?.usdCost ?? 0;
1631
1889
  const usage = toNormalizedUsage(runtime.parsedUsage);
@@ -1636,6 +1894,7 @@ export function toProviderResult(
1636
1894
  tokenInput: usageTokenInputTotal(runtime.parsedUsage),
1637
1895
  tokenOutput,
1638
1896
  usdCost,
1897
+ finalRunOutput: runtime.finalRunOutput,
1639
1898
  usage,
1640
1899
  pricingProviderType,
1641
1900
  pricingModelId,
@@ -1670,11 +1929,15 @@ export function toProviderResult(
1670
1929
  };
1671
1930
  }
1672
1931
  const failedUsage = resolveFailedUsage(runtime);
1673
- const failureDetail = resolveRuntimeFailureDetail(runtime);
1674
- const rateLimitedFailure = isRateLimitedRuntimeFailure(runtime, failureDetail);
1932
+ const failure = classifyProviderFailure(provider, {
1933
+ detail: resolveRuntimeFailureDetail(runtime, provider),
1934
+ stdout: runtime.stdout,
1935
+ stderr: runtime.stderr,
1936
+ failureType: runtime.failureType
1937
+ });
1675
1938
  return {
1676
1939
  status: "failed",
1677
- summary: runtime.parsedUsage?.summary ?? `${provider} runtime failed: ${failureDetail}`,
1940
+ summary: runtime.parsedUsage?.summary ?? `${provider} runtime failed: ${failure.detail}`,
1678
1941
  tokenInput: failedUsage.tokenInput,
1679
1942
  tokenOutput: failedUsage.tokenOutput,
1680
1943
  usdCost: failedUsage.usdCost,
@@ -1684,12 +1947,12 @@ export function toProviderResult(
1684
1947
  outcome: toOutcome({
1685
1948
  kind: "failed",
1686
1949
  issueIdsTouched: issueIdsTouched(context),
1687
- actions: [{ type: "runtime.execute", status: "error", detail: failureDetail }],
1950
+ actions: [{ type: "runtime.execute", status: "error", detail: failure.detail }],
1688
1951
  blockers: [
1689
1952
  {
1690
- code: runtime.failureType ?? "runtime_failed",
1691
- message: failureDetail,
1692
- retryable: !rateLimitedFailure
1953
+ code: failure.blockerCode,
1954
+ message: failure.detail,
1955
+ retryable: failure.retryable
1693
1956
  }
1694
1957
  ],
1695
1958
  artifacts: [],
@@ -1714,6 +1977,9 @@ export function toProviderResult(
1714
1977
  stderrPreview: toPreview(runtime.stderr),
1715
1978
  transcript: runtime.transcript
1716
1979
  },
1980
+ ...(failure.providerUsageLimited
1981
+ ? { dispositionHint: buildProviderUsageLimitedDispositionHint(provider, failure.detail) }
1982
+ : {}),
1717
1983
  nextState: applyProviderSessionState(context, provider, sessionUpdate)
1718
1984
  };
1719
1985
  }
@@ -1724,29 +1990,99 @@ export function resolveRuntimeFailureDetail(runtime: {
1724
1990
  code: number | null;
1725
1991
  failureType?: "timeout" | "spawn_error" | "nonzero_exit";
1726
1992
  attempts: Array<{ spawnErrorCode?: string }>;
1727
- }) {
1993
+ }, provider?: AgentProviderType) {
1728
1994
  const stderr = runtime.stderr.trim();
1995
+ const normalize = (detail: string) => (provider ? normalizeProviderFailureDetail(provider, detail) : detail);
1729
1996
  if (stderr.length > 0) {
1730
- return stderr;
1997
+ return normalize(extractStructuredRuntimeErrorDetail(stderr) ?? stderr);
1731
1998
  }
1732
1999
  const lastAttempt = runtime.attempts[runtime.attempts.length - 1];
1733
2000
  if (runtime.failureType === "spawn_error") {
1734
2001
  if (lastAttempt?.spawnErrorCode) {
1735
- return `failed to launch runtime command (${lastAttempt.spawnErrorCode}). Verify the CLI is installed and on PATH.`;
2002
+ return normalize(`failed to launch runtime command (${lastAttempt.spawnErrorCode}). Verify the CLI is installed and on PATH.`);
1736
2003
  }
1737
- return "failed to launch runtime command. Verify the CLI is installed and on PATH.";
2004
+ return normalize("failed to launch runtime command. Verify the CLI is installed and on PATH.");
1738
2005
  }
1739
2006
  if (runtime.failureType === "timeout") {
1740
- return "timed out before completion. Increase runtimeTimeoutSec for this agent/runtime.";
2007
+ return normalize("timed out before completion. Increase runtimeTimeoutSec for this agent/runtime.");
1741
2008
  }
1742
2009
  if (runtime.code !== null) {
1743
- return `process exited with code ${runtime.code} without stderr output.`;
2010
+ return normalize(`process exited with code ${runtime.code} without stderr output.`);
1744
2011
  }
1745
2012
  const stdout = runtime.stdout.trim();
1746
2013
  if (stdout.length > 0) {
1747
- return `no stderr output; stdout preview: ${toPreview(stdout, 320)}`;
2014
+ const structuredStdoutDetail = extractStructuredRuntimeErrorDetail(stdout);
2015
+ if (structuredStdoutDetail) {
2016
+ return normalize(structuredStdoutDetail);
2017
+ }
2018
+ return normalize(`no stderr output; stdout preview: ${toPreview(stdout, 320)}`);
2019
+ }
2020
+ return normalize("runtime exited without diagnostic output.");
2021
+ }
2022
+
2023
+ function extractStructuredRuntimeErrorDetail(text: string) {
2024
+ const normalized = text.trim();
2025
+ if (!normalized) {
2026
+ return null;
2027
+ }
2028
+ const candidatePayloads = collectJsonObjectCandidates(normalized);
2029
+ for (const candidate of candidatePayloads) {
2030
+ try {
2031
+ const parsed = JSON.parse(candidate) as unknown;
2032
+ const detail = extractErrorDetailFromUnknown(parsed);
2033
+ if (detail) {
2034
+ return detail;
2035
+ }
2036
+ } catch {
2037
+ // ignore malformed JSON fragments
2038
+ }
2039
+ }
2040
+ return null;
2041
+ }
2042
+
2043
+ function collectJsonObjectCandidates(text: string) {
2044
+ const candidates: string[] = [];
2045
+ if (text.startsWith("{") && text.endsWith("}")) {
2046
+ candidates.push(text);
2047
+ }
2048
+ for (const line of text.split(/\r?\n/)) {
2049
+ const trimmed = line.trim();
2050
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
2051
+ candidates.push(trimmed);
2052
+ }
1748
2053
  }
1749
- return "runtime exited without diagnostic output.";
2054
+ return candidates;
2055
+ }
2056
+
2057
+ function extractErrorDetailFromUnknown(value: unknown): string | null {
2058
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2059
+ return null;
2060
+ }
2061
+ const record = value as Record<string, unknown>;
2062
+ const directCandidates = [
2063
+ record.detail,
2064
+ record.message,
2065
+ record.summary,
2066
+ record.reason,
2067
+ record.error,
2068
+ record.description
2069
+ ];
2070
+ for (const candidate of directCandidates) {
2071
+ if (typeof candidate === "string" && candidate.trim()) {
2072
+ return candidate.trim();
2073
+ }
2074
+ }
2075
+ const nestedError = record.error;
2076
+ if (nestedError && typeof nestedError === "object" && !Array.isArray(nestedError)) {
2077
+ const nestedRecord = nestedError as Record<string, unknown>;
2078
+ const nestedCandidates = [nestedRecord.detail, nestedRecord.message, nestedRecord.reason, nestedRecord.description];
2079
+ for (const candidate of nestedCandidates) {
2080
+ if (typeof candidate === "string" && candidate.trim()) {
2081
+ return candidate.trim();
2082
+ }
2083
+ }
2084
+ }
2085
+ return null;
1750
2086
  }
1751
2087
 
1752
2088
  export function parseOpenCodeOutput(stdout: string) {
@@ -2291,12 +2627,15 @@ export function createPrompt(context: HeartbeatContext) {
2291
2627
  const agentGoals = context.goalContext?.agentGoals.length
2292
2628
  ? context.goalContext.agentGoals.map((goal) => `- ${goal}`).join("\n")
2293
2629
  : "- No active agent goals";
2630
+ const isCommentOrderRun = context.wakeContext?.reason === "issue_comment_recipient";
2294
2631
  const workItems = context.workItems.length
2295
2632
  ? context.workItems
2296
2633
  .map((item) =>
2297
2634
  [
2298
2635
  `- [${item.issueId}] ${item.title}`,
2299
2636
  ` Project: ${item.projectName ?? item.projectId}`,
2637
+ item.parentIssueId ? ` Parent issue: ${item.parentIssueId}` : null,
2638
+ item.childIssueIds?.length ? ` Sub-issues: ${item.childIssueIds.join(", ")}` : null,
2300
2639
  item.status ? ` Status: ${item.status}` : null,
2301
2640
  item.priority ? ` Priority: ${item.priority}` : null,
2302
2641
  item.body ? ` Body: ${item.body}` : null,
@@ -2316,6 +2655,25 @@ export function createPrompt(context: HeartbeatContext) {
2316
2655
  )
2317
2656
  .join("\n")
2318
2657
  : "- No assigned work";
2658
+ const wakeContextLines = context.wakeContext
2659
+ ? [
2660
+ "Wake context:",
2661
+ `- Reason: ${context.wakeContext.reason ?? "unspecified"}`,
2662
+ `- Trigger comment: ${context.wakeContext.commentId ?? "none"}`,
2663
+ `- Comment order: ${context.wakeContext.commentBody ?? "none"}`,
2664
+ `- Linked issues: ${context.wakeContext.issueIds?.length ? context.wakeContext.issueIds.join(", ") : "none"}`
2665
+ ].join("\n")
2666
+ : "";
2667
+ const commentOrderDirectives =
2668
+ isCommentOrderRun
2669
+ ? [
2670
+ "Comment-order directives:",
2671
+ "- The triggering comment is the primary order for this run.",
2672
+ "- Treat linked issue details as read-only context unless explicitly asked for broader issue updates.",
2673
+ "- Do not rerun full issue backlogs/checklists by default.",
2674
+ "- Apply only the requested delta from the comment unless explicitly asked to do more."
2675
+ ].join("\n")
2676
+ : "";
2319
2677
  const memoryContext = context.memoryContext;
2320
2678
  const memoryTacitNotes = memoryContext?.tacitNotes?.trim()
2321
2679
  ? memoryContext.tacitNotes.trim()
@@ -2353,20 +2711,32 @@ export function createPrompt(context: HeartbeatContext) {
2353
2711
  "- You are running inside a BopoDev heartbeat for local repository work.",
2354
2712
  "- Use BopoDev-specific injected skills only (bopodev-control-plane, bopodev-create-agent, para-memory-files) when relevant.",
2355
2713
  "- Ignore unrelated third-party control-plane skills even if they exist in the runtime environment.",
2356
- "- Prefer completing assigned issue work in this repository over non-essential coordination tasks.",
2357
- "- Keep command usage minimal and task-focused; avoid broad repository scans unless strictly required for the assigned issue.",
2714
+ isCommentOrderRun
2715
+ ? "- Prioritize the triggering comment order over general issue backlog work."
2716
+ : "- Prefer completing assigned issue work in this repository over non-essential coordination tasks.",
2717
+ isCommentOrderRun
2718
+ ? "- Keep command usage narrowly focused on the comment request and required context."
2719
+ : "- Keep command usage minimal and task-focused; avoid broad repository scans unless strictly required for the assigned issue.",
2358
2720
  "- Shell commands run under zsh on macOS; avoid Bash-only features such as `local -n`, `declare -n`, `mapfile`, and `readarray`.",
2359
2721
  "- Prefer POSIX/zsh-compatible shell snippets, direct `curl` headers, and `jq`.",
2360
2722
  "- Prefer heredoc/stdin payloads (for example `curl --data-binary @- <<'JSON' ... JSON`) so cleanup is not blocked by runtime policy.",
2361
2723
  "- If payload files are required, write under `agents/<agent-id>/tmp/` (or OS temp via `mktemp`) and do not treat cleanup command failures as task blockers.",
2362
2724
  "- If control-plane API connectivity fails, report the exact failing command/error once and stop retry loops for the same endpoint.",
2363
2725
  "- For write_todos status values, only use: todo, in_progress, blocked, in_review, done, canceled (US spelling, not cancelled).",
2364
- "- If any command fails, avoid further exploratory commands and still return the required final JSON summary.",
2365
- "- Do not stop after planning. You must execute concrete steps for assigned issues in this run (file edits, API calls, or other verifiable actions).",
2366
- "- If you cannot complete concrete execution, set summary to include the blocker explicitly instead of claiming success.",
2726
+ "- If any command fails, avoid further exploratory commands and still return the required final JSON object.",
2727
+ "- Do not use emojis in issue comments, summaries, or status messages.",
2728
+ isCommentOrderRun
2729
+ ? "- Do not stop after planning. Execute concrete steps only for the triggering comment order."
2730
+ : "- Do not stop after planning. You must execute concrete steps for assigned issues in this run (file edits, API calls, or other verifiable actions).",
2731
+ "- If you cannot complete concrete execution, explain the blocker plainly in `employee_comment` and add it to `errors` instead of claiming success.",
2367
2732
  "- Treat file memory as source of truth for long-term context: append raw observations to daily notes first, then promote stable patterns to durable facts.",
2368
2733
  "- Avoid writing duplicate durable facts when existing memory already contains the same lesson.",
2369
- "- Your final output must be only the JSON object below, with no prose before or after it.",
2734
+ "- Your final output must be exactly one JSON object and nothing else.",
2735
+ "- Do not include any fields besides `employee_comment`, `results`, `errors`, and `artifacts`.",
2736
+ "- `employee_comment` must be markdown written like a concise employee updating a manager with concrete actions, outcome, and blocker or next step when relevant.",
2737
+ "- `results` must list concrete completed outcomes as short strings.",
2738
+ "- `errors` must list concrete blockers or failures as short strings and be empty on clean success.",
2739
+ "- `artifacts` must contain objects like {\"kind\":\"file\",\"path\":\"relative/path\"}.",
2370
2740
  "- Do not invent token or cost values; the runtime records usage separately."
2371
2741
  ].join("\n");
2372
2742
 
@@ -2385,9 +2755,13 @@ ${projectGoals}
2385
2755
  Agent goals:
2386
2756
  ${agentGoals}
2387
2757
 
2388
- Assigned issues:
2758
+ ${isCommentOrderRun ? "Linked issue context (read-only):" : "Assigned issues:"}
2389
2759
  ${workItems}
2390
2760
 
2761
+ ${wakeContextLines}
2762
+
2763
+ ${commentOrderDirectives}
2764
+
2391
2765
  Memory context:
2392
2766
  - Memory root: ${memoryContext?.memoryRoot ?? "Unavailable"}
2393
2767
  - Tacit notes:
@@ -2401,8 +2775,8 @@ ${executionDirectives}
2401
2775
 
2402
2776
  ${controlPlaneDirectives}
2403
2777
 
2404
- At the end of your response, output exactly one JSON object on a single line and nothing else:
2405
- {"summary":"brief outcome and any blocker"}
2778
+ At the end of your response, output exactly one JSON object on a single line and nothing else. Use this exact schema:
2779
+ {"employee_comment":"markdown update to the manager","results":["short concrete outcome"],"errors":[],"artifacts":[{"kind":"file","path":"relative/path"}]}
2406
2780
  `;
2407
2781
  }
2408
2782