@smithers-orchestrator/engine 0.16.9 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/engine",
3
- "version": "0.16.9",
3
+ "version": "0.18.0",
4
4
  "description": "Concrete Smithers workflow execution engine",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -20,27 +20,36 @@
20
20
  "src/"
21
21
  ],
22
22
  "dependencies": {
23
+ "@effect/cluster": "^0.58.0",
24
+ "@effect/experimental": "^0.60.0",
23
25
  "@effect/platform-bun": "^0.89.0",
26
+ "@effect/rpc": "^0.75.0",
27
+ "@effect/sql": "^0.51.0",
24
28
  "@effect/sql-sqlite-bun": "^0.52.0",
29
+ "@effect/workflow": "^0.18.0",
30
+ "diff": "^9.0.0",
31
+ "drizzle-orm": "^0.45.2",
25
32
  "effect": "^3.21.1",
26
- "@smithers-orchestrator/agents": "0.16.9",
27
- "@smithers-orchestrator/db": "0.16.9",
28
- "@smithers-orchestrator/components": "0.16.9",
29
- "@smithers-orchestrator/driver": "0.16.9",
30
- "@smithers-orchestrator/errors": "0.16.9",
31
- "@smithers-orchestrator/graph": "0.16.9",
32
- "@smithers-orchestrator/observability": "0.16.9",
33
- "@smithers-orchestrator/react-reconciler": "0.16.9",
34
- "@smithers-orchestrator/sandbox": "0.16.9",
35
- "@smithers-orchestrator/memory": "0.16.9",
36
- "@smithers-orchestrator/scheduler": "0.16.9",
37
- "@smithers-orchestrator/scorers": "0.16.9",
38
- "@smithers-orchestrator/vcs": "0.16.9",
39
- "@smithers-orchestrator/time-travel": "0.16.9"
33
+ "react": "^19.2.5",
34
+ "react-dom": "^19.2.5",
35
+ "zod": "^4.3.6",
36
+ "@smithers-orchestrator/agents": "0.18.0",
37
+ "@smithers-orchestrator/db": "0.18.0",
38
+ "@smithers-orchestrator/driver": "0.18.0",
39
+ "@smithers-orchestrator/errors": "0.18.0",
40
+ "@smithers-orchestrator/graph": "0.18.0",
41
+ "@smithers-orchestrator/memory": "0.18.0",
42
+ "@smithers-orchestrator/observability": "0.18.0",
43
+ "@smithers-orchestrator/scheduler": "0.18.0",
44
+ "@smithers-orchestrator/scorers": "0.18.0",
45
+ "@smithers-orchestrator/time-travel": "0.18.0",
46
+ "@smithers-orchestrator/components": "0.18.0",
47
+ "@smithers-orchestrator/react-reconciler": "0.18.0",
48
+ "@smithers-orchestrator/sandbox": "0.18.0",
49
+ "@smithers-orchestrator/vcs": "0.18.0"
40
50
  },
41
51
  "devDependencies": {
42
52
  "@types/bun": "latest",
43
- "react": "^19.2.5",
44
53
  "typescript": "~5.9.3"
45
54
  },
46
55
  "scripts": {
package/src/approvals.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Effect, Metric } from "effect";
2
2
  import { nowMs } from "@smithers-orchestrator/scheduler/nowMs";
3
- import { SmithersDb } from "@smithers-orchestrator/db/adapter";
4
3
  import { approvalWaitDuration, trackEvent, updateAsyncExternalWaitPending, } from "@smithers-orchestrator/observability/metrics";
5
4
  import { bridgeApprovalResolve } from "./effect/durable-deferred-bridge.js";
6
5
  import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
@@ -298,7 +298,7 @@ function resolveHandleIteration(handle, ctx) {
298
298
  * @param {Record<string, unknown>} row
299
299
  */
300
300
  function stripPersistedKeys(row) {
301
- const { runId, nodeId, iteration, payload, ...rest } = row;
301
+ const { runId: _runId, nodeId: _nodeId, iteration: _iteration, payload, ...rest } = row;
302
302
  if (payload !== undefined)
303
303
  return payload;
304
304
  return rest;
@@ -754,7 +754,7 @@ function normalizeExecutionError(result) {
754
754
  /**
755
755
  * @param {{ name: string; input: AnySchema }} options
756
756
  */
757
- function createWorkflow(options) {
757
+ function _createWorkflow(options) {
758
758
  return {
759
759
  /**
760
760
  * @param {($: BuilderApi) => BuilderNode} buildGraph
@@ -804,7 +804,7 @@ function createWorkflow(options) {
804
804
  /**
805
805
  * @param {{ name: string; params?: Record<string, unknown> }} options
806
806
  */
807
- function createComponent(options) {
807
+ function _createComponent(options) {
808
808
  return {
809
809
  /**
810
810
  * @param {($: BuilderApi, params: Record<string, unknown>) => BuilderNode} buildGraph
@@ -1,9 +1,7 @@
1
1
  import { Cause, Duration, Effect, Either, Exit, Metric, Schedule } from "effect";
2
- import { z } from "zod";
3
2
  import { buildOutputRow, stripAutoColumns, validateOutput } from "@smithers-orchestrator/db/output";
4
3
  import { TaskHeartbeatTimeout } from "@smithers-orchestrator/errors/TaskHeartbeatTimeout";
5
4
  import { TaskTimeout } from "@smithers-orchestrator/errors/TaskTimeout";
6
- import { EventBus } from "../events.js";
7
5
  import { makeAbortError, wireAbortSignal } from "./bridge-utils.js";
8
6
  import { withTaskRuntime } from "@smithers-orchestrator/driver/task-runtime";
9
7
  import { logDebug, logError, logInfo, logWarning } from "@smithers-orchestrator/observability/logging";
@@ -623,6 +621,14 @@ export const executeComputeTaskBridge = async (adapter, db, runId, desc, eventBu
623
621
  if (isHeartbeatPayloadValidationError(effectiveError)) {
624
622
  attemptMeta.failureRetryable = false;
625
623
  }
624
+ // Propagate non-retryable signal from any thrown SmithersError so the
625
+ // attempt is not retried (e.g. AGENT_CONFIG_INVALID from KimiAgent's
626
+ // expired-credentials check, or auth-failure patterns classified by
627
+ // BaseCliAgent.classifyNonRetryableAgentError).
628
+ if (effectiveError?.details?.failureRetryable === false ||
629
+ effectiveError?.code === "AGENT_CONFIG_INVALID") {
630
+ attemptMeta.failureRetryable = false;
631
+ }
626
632
  if (aborted) {
627
633
  await waitForHeartbeatWriteDrain();
628
634
  await flushHeartbeat(true);
@@ -3,7 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server";
3
3
  import { Effect, Exit } from "effect";
4
4
  import { buildOutputRow, describeSchemaShape, selectOutputRow, stripAutoColumns, validateExistingOutput, validateOutput, } from "@smithers-orchestrator/db/output";
5
5
  import { awaitApprovalDurableDeferred, awaitWaitForEventDurableDeferred, bridgeApprovalResolve, bridgeWaitForEventResolve, } from "./durable-deferred-bridge.js";
6
- import { EventBus } from "../events.js";
7
6
  import { buildHumanRequestId, getHumanTaskPrompt as getStoredHumanTaskPrompt, isHumanTaskMeta, } from "../human-requests.js";
8
7
  import { parseAttemptMetaJson } from "./bridge-utils.js";
9
8
  import { updateAsyncExternalWaitPending } from "@smithers-orchestrator/observability/metrics";
@@ -344,7 +344,7 @@ export async function applyDiffBundle(bundle, targetDir) {
344
344
  await runGit(targetDir, ["apply", "--binary", "--whitespace=nowarn", "--unsafe-paths", "-"], { input: fullPatch });
345
345
  return;
346
346
  }
347
- catch (error) {
347
+ catch {
348
348
  for (const patch of bundle.patches) {
349
349
  await applyPatchFallback(patch, targetDir);
350
350
  }
@@ -1,7 +1,5 @@
1
1
  import { Effect, Metric } from "effect";
2
- import { z } from "zod";
3
2
  import { buildOutputRow, stripAutoColumns, validateOutput } from "@smithers-orchestrator/db/output";
4
- import { EventBus } from "../events.js";
5
3
  import { makeAbortError, wireAbortSignal } from "./bridge-utils.js";
6
4
  import { logDebug, logError, logInfo } from "@smithers-orchestrator/observability/logging";
7
5
  import { attemptDuration, nodeDuration } from "@smithers-orchestrator/observability/metrics";
@@ -1,6 +1,4 @@
1
1
  import { Effect } from "effect";
2
- import { SmithersDb } from "@smithers-orchestrator/db/adapter";
3
- import { EventBus } from "../events.js";
4
2
  import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
5
3
  import { makeWorkerTask, } from "./entity-worker.js";
6
4
  import { executeTaskActivity, makeTaskBridgeKey, RetriableTaskFailure, } from "./activity-bridge.js";
@@ -74,8 +72,12 @@ function isRetryableBridgeTaskFailure(attempt) {
74
72
  if (meta?.failureRetryable === false) {
75
73
  return false;
76
74
  }
75
+ const errorCode = parseAttemptErrorCode(attempt?.errorJson);
76
+ if (errorCode === "AGENT_CONFIG_INVALID") {
77
+ return false;
78
+ }
77
79
  const kind = typeof meta?.kind === "string" ? meta.kind : null;
78
- return !(kind !== "agent" && parseAttemptErrorCode(attempt?.errorJson) === "INVALID_OUTPUT");
80
+ return !(kind !== "agent" && errorCode === "INVALID_OUTPUT");
79
81
  }
80
82
  /**
81
83
  * @param {SmithersDb} adapter
package/src/engine.js CHANGED
@@ -5,7 +5,7 @@ import { SmithersCtx } from "@smithers-orchestrator/driver/SmithersCtx";
5
5
  import { loadInput, loadOutputs } from "@smithers-orchestrator/db/snapshot";
6
6
  import { ensureSmithersTables } from "@smithers-orchestrator/db/ensure";
7
7
  import { SmithersDb } from "@smithers-orchestrator/db/adapter";
8
- import { selectOutputRow, validateOutput, validateExistingOutput, getAgentOutputSchema, describeSchemaShape, buildOutputRow, stripAutoColumns, } from "@smithers-orchestrator/db/output";
8
+ import { selectOutputRow, validateOutput, validateExistingOutput, describeSchemaShape, buildOutputRow, stripAutoColumns, } from "@smithers-orchestrator/db/output";
9
9
  import { validateInput } from "@smithers-orchestrator/db/input";
10
10
  import { schemaSignature } from "@smithers-orchestrator/db/schema-signature";
11
11
  import { withSqliteWriteRetry } from "@smithers-orchestrator/db/write-retry";
@@ -23,10 +23,9 @@ import { EventBus } from "./events.js";
23
23
  import { getJjPointer, runJj, workspaceAdd } from "@smithers-orchestrator/vcs/jj";
24
24
  import { findVcsRoot } from "@smithers-orchestrator/vcs/find-root";
25
25
  import * as BunContext from "@effect/platform-bun/BunContext";
26
- import { z } from "zod";
27
26
  import { eq, getTableName } from "drizzle-orm";
28
27
  import { getTableColumns } from "drizzle-orm/utils";
29
- import { Chunk, Duration, Effect, Fiber, Metric, Queue, Schedule } from "effect";
28
+ import { Cause, Chunk, Duration, Effect, Exit, Fiber, Metric, Queue, Schedule } from "effect";
30
29
  import { attemptDuration, cacheHits, cacheMisses, nodeDuration, promptSizeBytes, responseSizeBytes, runDuration, runsResumedTotal, schedulerConcurrencyUtilization, schedulerQueueDepth, schedulerWaitDuration, trackEvent, } from "@smithers-orchestrator/observability/metrics";
31
30
  import { runScorersAsync } from "@smithers-orchestrator/scorers/run-scorers";
32
31
  import { dirname, resolve } from "node:path";
@@ -288,6 +287,25 @@ function isHeartbeatPayloadValidationError(err) {
288
287
  return (code === "HEARTBEAT_PAYLOAD_NOT_JSON_SERIALIZABLE" ||
289
288
  code === "HEARTBEAT_PAYLOAD_TOO_LARGE");
290
289
  }
290
+ /**
291
+ * Effect.runPromise rejects with a FiberFailure wrapper. For task execution we
292
+ * need the original failure so retry metadata can read SmithersError fields.
293
+ *
294
+ * @template A
295
+ * @param {Effect.Effect<A, unknown>} effect
296
+ * @returns {Promise<A>}
297
+ */
298
+ async function runPromisePreservingFailure(effect) {
299
+ const exit = await Effect.runPromiseExit(effect);
300
+ if (Exit.isSuccess(exit)) {
301
+ return exit.value;
302
+ }
303
+ const failure = Cause.failureOption(exit.cause);
304
+ if (failure._tag === "Some") {
305
+ throw failure.value;
306
+ }
307
+ throw Cause.squash(exit.cause);
308
+ }
291
309
  /**
292
310
  * @param {Record<string, unknown>} meta
293
311
  * @param {string} engine
@@ -448,16 +466,6 @@ function prependToolResumeWarningMessage(prompt, warningMessage) {
448
466
  }
449
467
  return `${warningMessage}\n\n${prompt}`;
450
468
  }
451
- /**
452
- * @param {HijackCompletion} completion
453
- * @returns {Error}
454
- */
455
- function buildHijackAbortError(completion) {
456
- const err = makeAbortError(`Hijack requested for ${completion.engine}`);
457
- err.code = "RUN_HIJACKED";
458
- err.hijack = completion;
459
- return err;
460
- }
461
469
  /**
462
470
  * @param {string} cwd
463
471
  * @param {string[]} args
@@ -1526,13 +1534,11 @@ function assertResumeDurabilityMetadata(existingRun, existingConfig, current, wo
1526
1534
  else if (shouldCheckWorkflowHashes) {
1527
1535
  compareNullableString(existingRun.workflowHash, current.entryWorkflowHash, "workflow entry file changed", mismatches);
1528
1536
  }
1529
- compareNullableString(existingRun.vcsType, current.vcsType, "VCS type changed", mismatches);
1530
1537
  if ((existingRun.vcsRoot && current.vcsRoot
1531
1538
  ? resolve(existingRun.vcsRoot) !== resolve(current.vcsRoot)
1532
1539
  : (existingRun.vcsRoot ?? null) !== (current.vcsRoot ?? null))) {
1533
1540
  mismatches.push("VCS root changed");
1534
1541
  }
1535
- compareNullableString(existingRun.vcsRevision, current.vcsRevision, "VCS revision changed", mismatches);
1536
1542
  if (mismatches.length > 0) {
1537
1543
  throw new SmithersError("RESUME_METADATA_MISMATCH", `Cannot resume run because durable metadata changed: ${mismatches.join(", ")}`, {
1538
1544
  existing: {
@@ -1797,6 +1803,7 @@ function resolveTaskOutputs(tasks, workflow) {
1797
1803
  if (isTimerTask(task)) {
1798
1804
  continue;
1799
1805
  }
1806
+ const hasAmbiguousOutputRef = Boolean(task.outputRef && workflow.ambiguousZodSchemas?.has(task.outputRef));
1800
1807
  // Already resolved (has a table)
1801
1808
  if (task.outputTable) {
1802
1809
  if (!task.outputSchema && task.outputTableName && workflow.schemaRegistry) {
@@ -1820,10 +1827,14 @@ function resolveTaskOutputs(tasks, workflow) {
1820
1827
  }
1821
1828
  }
1822
1829
  if (!task.outputTable) {
1830
+ if (hasAmbiguousOutputRef) {
1831
+ throw new SmithersError("UNKNOWN_OUTPUT_SCHEMA", `Task "${task.nodeId}" uses an output schema that is registered under multiple keys. Use createSmithers(...).outputs.<key> or a string output key instead of the shared raw Zod object.`);
1832
+ }
1823
1833
  throw new SmithersError("UNKNOWN_OUTPUT_SCHEMA", `Task "${task.nodeId}" uses an output ZodObject that is not registered in createSmithers()`);
1824
1834
  }
1825
1835
  }
1826
1836
  const raw = task.outputSchema;
1837
+ const hasAmbiguousOutputSchema = Boolean(raw && typeof raw === "object" && workflow.ambiguousZodSchemas?.has(raw));
1827
1838
  // Resolve ZodObject via outputSchema when no outputRef resolved.
1828
1839
  if (!task.outputTable && raw && typeof raw === "object" && workflow.zodToKeyName) {
1829
1840
  const keyName = workflow.zodToKeyName.get(raw);
@@ -1837,6 +1848,9 @@ function resolveTaskOutputs(tasks, workflow) {
1837
1848
  }
1838
1849
  }
1839
1850
  if (!task.outputTable) {
1851
+ if (hasAmbiguousOutputSchema) {
1852
+ throw new SmithersError("UNKNOWN_OUTPUT_SCHEMA", `Task "${task.nodeId}" uses an output schema that is registered under multiple keys. Use createSmithers(...).outputs.<key> or a string output key instead of the shared raw Zod object.`);
1853
+ }
1840
1854
  throw new SmithersError("UNKNOWN_OUTPUT_SCHEMA", `Task "${task.nodeId}" uses an output ZodObject that is not registered in createSmithers()`);
1841
1855
  }
1842
1856
  }
@@ -2031,8 +2045,15 @@ function isRetryableTaskFailure(attempt) {
2031
2045
  if (meta?.failureRetryable === false) {
2032
2046
  return false;
2033
2047
  }
2048
+ const errorCode = parseAttemptErrorCode(attempt?.errorJson);
2049
+ // AGENT_CONFIG_INVALID is a deterministic configuration failure (e.g.
2050
+ // "LLM not set", unknown model). Retrying is guaranteed to fail again
2051
+ // and just multiplies cost — short-circuit immediately.
2052
+ if (errorCode === "AGENT_CONFIG_INVALID") {
2053
+ return false;
2054
+ }
2034
2055
  const kind = typeof meta?.kind === "string" ? meta.kind : null;
2035
- return !(kind !== "agent" && parseAttemptErrorCode(attempt?.errorJson) === "INVALID_OUTPUT");
2056
+ return !(kind !== "agent" && errorCode === "INVALID_OUTPUT");
2036
2057
  }
2037
2058
  /**
2038
2059
  * @param {SmithersDb} adapter
@@ -2560,6 +2581,7 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
2560
2581
  let cacheJjBase = null;
2561
2582
  let responseText = null;
2562
2583
  let effectiveAgent = null;
2584
+ let supportsNativeStructuredOutput = false;
2563
2585
  // Resolve effective root once so both caching and execution share it.
2564
2586
  const taskRoot = desc.worktreePath ?? toolConfig.rootDir;
2565
2587
  const stepCacheEnabled = cacheEnabled || Boolean(desc.cachePolicy);
@@ -2766,13 +2788,15 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
2766
2788
  effectiveAgent.model ??
2767
2789
  effectiveAgent.modelId ??
2768
2790
  null;
2769
- const currentAgentEngine = typeof effectiveAgent.cliEngine === "string"
2791
+ const hijackCapableEngine = typeof effectiveAgent.cliEngine === "string"
2770
2792
  ? effectiveAgent.cliEngine
2771
2793
  : typeof effectiveAgent.hijackEngine === "string"
2772
2794
  ? effectiveAgent.hijackEngine
2773
- : (typeof effectiveAgent.constructor?.name === "string"
2774
- ? effectiveAgent.constructor.name
2775
- : null);
2795
+ : null;
2796
+ const currentAgentEngine = hijackCapableEngine ??
2797
+ (typeof effectiveAgent.constructor?.name === "string"
2798
+ ? effectiveAgent.constructor.name
2799
+ : null);
2776
2800
  attemptMeta.agentEngine = currentAgentEngine;
2777
2801
  const heartbeatCheckpoint = previousHeartbeat &&
2778
2802
  typeof previousHeartbeat === "object" &&
@@ -2785,15 +2809,25 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
2785
2809
  const heartbeatCheckpointUsable = !currentAgentEngine ||
2786
2810
  !heartbeatCheckpointEngine ||
2787
2811
  heartbeatCheckpointEngine === currentAgentEngine;
2788
- const checkpointResumeSession = heartbeatCheckpointUsable &&
2789
- typeof heartbeatCheckpoint?.agentResume === "string"
2812
+ // If the most recent failed attempt asked us to drop the resume
2813
+ // session (e.g. kimi crashed mid-stream and reported `kimi -r
2814
+ // <uuid>`; that session is now corrupt and re-resuming it just
2815
+ // reproduces the crash), don't reuse the captured agentResume
2816
+ // from the heartbeat. Forces the agent to start a fresh
2817
+ // session on the next attempt.
2818
+ const lastFailedAttempt = attempts.find((a) => a.state === "failed");
2819
+ const lastFailedMeta = parseAttemptMetaJson(lastFailedAttempt?.metaJson);
2820
+ const discardResumeSession = lastFailedMeta?.discardResumeSession === true;
2821
+ const checkpointResumeSession = !discardResumeSession
2822
+ && heartbeatCheckpointUsable
2823
+ && typeof heartbeatCheckpoint?.agentResume === "string"
2790
2824
  ? heartbeatCheckpoint.agentResume
2791
2825
  : undefined;
2792
2826
  const checkpointResumeMessages = heartbeatCheckpointUsable
2793
2827
  ? asConversationMessages(heartbeatCheckpoint?.agentConversation)
2794
2828
  : undefined;
2795
- const priorContinuation = currentAgentEngine
2796
- ? findHijackContinuation(attempts, currentAgentEngine)
2829
+ const priorContinuation = hijackCapableEngine
2830
+ ? findHijackContinuation(attempts, hijackCapableEngine)
2797
2831
  : undefined;
2798
2832
  const resumeSession = priorContinuation?.mode === "native-cli"
2799
2833
  ? priorContinuation.resume
@@ -2803,6 +2837,37 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
2803
2837
  : (cloneJsonValue(checkpointResumeMessages) ??
2804
2838
  checkpointResumeMessages);
2805
2839
  const guidedResumeMessages = appendToolResumeWarningMessage(resumeMessages, toolResumeWarningMessage);
2840
+ if (desc.hijack) {
2841
+ if (!hijackCapableEngine) {
2842
+ attemptMeta.failureRetryable = false;
2843
+ throw new SmithersError("TASK_HIJACK_UNSUPPORTED", `Task ${desc.nodeId} sets hijack, but its agent is not hijack-capable. Hijack requires an agent with cliEngine or hijackEngine.`, {
2844
+ nodeId: desc.nodeId,
2845
+ agentId: attemptMeta.agentId ?? undefined,
2846
+ });
2847
+ }
2848
+ const shouldAutoHijack = desc.onHijackExit === "reopen" || !priorContinuation;
2849
+ if (shouldAutoHijack && !hijackState) {
2850
+ attemptMeta.failureRetryable = false;
2851
+ throw new SmithersError("TASK_HIJACK_UNSUPPORTED", `Task ${desc.nodeId} cannot auto-hijack in this execution mode.`, {
2852
+ nodeId: desc.nodeId,
2853
+ agentId: attemptMeta.agentId ?? undefined,
2854
+ });
2855
+ }
2856
+ if (shouldAutoHijack && !hijackState.request && !hijackState.completion) {
2857
+ const requestedAtMs = nowMs();
2858
+ hijackState.request = {
2859
+ requestedAtMs,
2860
+ target: hijackCapableEngine,
2861
+ };
2862
+ await Effect.runPromise(adapter.requestRunHijack(runId, requestedAtMs, hijackCapableEngine));
2863
+ await Effect.runPromise(eventBus.emitEventWithPersist({
2864
+ type: "RunHijackRequested",
2865
+ runId,
2866
+ target: hijackCapableEngine,
2867
+ timestampMs: requestedAtMs,
2868
+ }));
2869
+ }
2870
+ }
2806
2871
  if (resumeSession) {
2807
2872
  attemptMeta.resumedFromSession = resumeSession;
2808
2873
  }
@@ -2837,7 +2902,8 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
2837
2902
  maybeCompleteHijack();
2838
2903
  };
2839
2904
  let effectivePrompt = desc.prompt ?? "";
2840
- if (desc.outputTable) {
2905
+ supportsNativeStructuredOutput = effectiveAgent.supportsNativeStructuredOutput === true;
2906
+ if (desc.outputTable && !supportsNativeStructuredOutput) {
2841
2907
  const schemaDesc = describeSchemaShape(desc.outputTable, desc.outputSchema);
2842
2908
  const jsonInstructions = [
2843
2909
  "**REQUIRED OUTPUT** — You MUST end your response with a JSON object in a code fence matching this schema:",
@@ -2989,37 +3055,40 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
2989
3055
  // Use fallback agent on retry attempts when available
2990
3056
  let result;
2991
3057
  try {
2992
- result = await Effect.runPromise(withSmithersSpan(smithersSpanNames.agent, Effect.promise(() => {
2993
- const agentCall = guidedResumeMessages?.length
2994
- ? {
2995
- messages: guidedResumeMessages,
2996
- }
2997
- : {
2998
- prompt: effectivePrompt,
2999
- };
3000
- return effectiveAgent.generate({
3001
- options: undefined,
3002
- abortSignal: taskSignal,
3003
- ...agentCall,
3004
- resumeSession,
3005
- lastHeartbeat: previousHeartbeat,
3006
- rootDir: taskRoot,
3007
- maxOutputBytes: toolConfig.maxOutputBytes,
3008
- timeout: desc.timeoutMs
3009
- ? { totalMs: desc.timeoutMs }
3010
- : undefined,
3011
- onStdout: (text) => {
3012
- recordInternalHeartbeat();
3013
- emitOutput(text, "stdout");
3014
- },
3015
- onStderr: (text) => {
3016
- recordInternalHeartbeat();
3017
- emitOutput(text, "stderr");
3018
- },
3019
- onEvent: handleAgentEvent,
3020
- onStepFinish: handleSdkStepFinish,
3021
- outputSchema: desc.outputSchema,
3022
- });
3058
+ result = await runPromisePreservingFailure(withSmithersSpan(smithersSpanNames.agent, Effect.tryPromise({
3059
+ try: () => {
3060
+ const agentCall = guidedResumeMessages?.length
3061
+ ? {
3062
+ messages: guidedResumeMessages,
3063
+ }
3064
+ : {
3065
+ prompt: effectivePrompt,
3066
+ };
3067
+ return effectiveAgent.generate({
3068
+ options: undefined,
3069
+ abortSignal: taskSignal,
3070
+ ...agentCall,
3071
+ resumeSession,
3072
+ lastHeartbeat: previousHeartbeat,
3073
+ rootDir: taskRoot,
3074
+ maxOutputBytes: toolConfig.maxOutputBytes,
3075
+ timeout: desc.timeoutMs
3076
+ ? { totalMs: desc.timeoutMs }
3077
+ : undefined,
3078
+ onStdout: (text) => {
3079
+ recordInternalHeartbeat();
3080
+ emitOutput(text, "stdout");
3081
+ },
3082
+ onStderr: (text) => {
3083
+ recordInternalHeartbeat();
3084
+ emitOutput(text, "stderr");
3085
+ },
3086
+ onEvent: handleAgentEvent,
3087
+ onStepFinish: handleSdkStepFinish,
3088
+ outputSchema: desc.outputSchema,
3089
+ });
3090
+ },
3091
+ catch: (error) => error,
3023
3092
  }), {
3024
3093
  ...taskSpanContext,
3025
3094
  agent: attemptMeta.agentId ??
@@ -3100,10 +3169,12 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
3100
3169
  // Fall back to parsing text/steps for JSON
3101
3170
  if (output === undefined) {
3102
3171
  const text = result.text ?? "";
3103
- // Try to parse the whole text as JSON first
3172
+ // Try to parse the whole text as JSON first. Strip a leading
3173
+ // UTF-8 BOM and accept either object or array at the root,
3174
+ // since Zod schemas occasionally validate arrays.
3104
3175
  try {
3105
- const trimmed = text.trim();
3106
- if (trimmed.startsWith("{")) {
3176
+ const trimmed = text.replace(/^\uFEFF/, "").trim();
3177
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
3107
3178
  output = JSON.parse(trimmed);
3108
3179
  }
3109
3180
  }
@@ -3280,14 +3351,28 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
3280
3351
  const retryText = retryResult.text ?? "";
3281
3352
  responseText = retryText || responseText;
3282
3353
  try {
3283
- const trimmed = retryText.trim();
3284
- if (trimmed.startsWith("{")) {
3354
+ const trimmed = retryText.replace(/^\uFEFF/, "").trim();
3355
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
3285
3356
  output = JSON.parse(trimmed);
3286
3357
  }
3287
3358
  }
3288
3359
  catch {
3289
3360
  // Still not valid JSON
3290
3361
  }
3362
+ if (output === undefined) {
3363
+ // Try extracting JSON from a markdown code fence
3364
+ // (```json ... ``` or just ``` ... ```).
3365
+ const fenceMatch = retryText.match(/```(?:json)?\s*([\s\S]*?)```/i);
3366
+ if (fenceMatch) {
3367
+ const inner = fenceMatch[1].trim();
3368
+ try {
3369
+ output = JSON.parse(inner);
3370
+ }
3371
+ catch {
3372
+ // Fall through to balanced extraction
3373
+ }
3374
+ }
3375
+ }
3291
3376
  if (output === undefined) {
3292
3377
  // Try extracting balanced JSON from retry text
3293
3378
  const jsonStr = extractBalancedJson(retryText);
@@ -3303,8 +3388,6 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
3303
3388
  }
3304
3389
  if (output === undefined) {
3305
3390
  // Debug: log what we have
3306
- const debugSteps = result.steps ?? [];
3307
- const stepTexts = debugSteps.map((s, i) => `Step ${i}: ${(s?.text ?? "").slice(0, 200)}`);
3308
3391
  const finishReason = result.finishReason ?? "unknown";
3309
3392
  logDebug("agent response did not contain valid JSON output", {
3310
3393
  runId,
@@ -3319,7 +3402,11 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
3319
3402
  lastStepText: debugSteps[debugSteps.length - 1]?.text?.slice(0, 500) ??
3320
3403
  "none",
3321
3404
  }, "engine:task-json");
3322
- throw new SmithersError("INVALID_OUTPUT", "No valid JSON output found in agent response");
3405
+ const tail = (text ?? "").slice(-200).replace(/\s+/g, " ").trim();
3406
+ const tailHint = tail
3407
+ ? ` Last 200 chars of response: ${JSON.stringify(tail)}`
3408
+ : " Agent returned an empty response.";
3409
+ throw new SmithersError("INVALID_OUTPUT", `No valid JSON output found in agent response (finishReason=${finishReason}, textLength=${text.length}).${tailHint}`);
3323
3410
  }
3324
3411
  }
3325
3412
  // Output should already be parsed, but handle string case
@@ -3327,7 +3414,7 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
3327
3414
  try {
3328
3415
  payload = JSON.parse(output);
3329
3416
  }
3330
- catch (e) {
3417
+ catch {
3331
3418
  throw new SmithersError("INVALID_OUTPUT", `Failed to parse agent output as JSON. Output starts with: "${output.slice(0, 100)}"`);
3332
3419
  }
3333
3420
  }
@@ -3428,17 +3515,25 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
3428
3515
  const zodIssues = validation.error?.issues
3429
3516
  ?.map((iss) => ` - ${(iss.path ?? []).join(".")}: ${iss.message}`)
3430
3517
  .join("\n") ?? "Unknown validation error";
3431
- const schemaRetryPrompt = [
3432
- `Your output didn't match the required schema. Validation errors:`,
3433
- zodIssues,
3434
- ``,
3435
- `Please return valid JSON matching the schema exactly.`,
3436
- ``,
3437
- `You MUST output ONLY a valid JSON object with exactly these fields and types:`,
3438
- schemaDesc,
3439
- ``,
3440
- `Output ONLY the JSON object, no other text.`,
3441
- ].join("\n");
3518
+ const schemaRetryPrompt = supportsNativeStructuredOutput
3519
+ ? [
3520
+ `Your structured output didn't match the required schema. Validation errors:`,
3521
+ zodIssues,
3522
+ ``,
3523
+ `Return corrected structured data matching this schema:`,
3524
+ schemaDesc,
3525
+ ].join("\n")
3526
+ : [
3527
+ `Your output didn't match the required schema. Validation errors:`,
3528
+ zodIssues,
3529
+ ``,
3530
+ `Please return valid JSON matching the schema exactly.`,
3531
+ ``,
3532
+ `You MUST output ONLY a valid JSON object with exactly these fields and types:`,
3533
+ schemaDesc,
3534
+ ``,
3535
+ `Output ONLY the JSON object, no other text.`,
3536
+ ].join("\n");
3442
3537
  logInfo("schema validation retry", {
3443
3538
  runId,
3444
3539
  nodeId: desc.nodeId,
@@ -3468,6 +3563,9 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
3468
3563
  recordInternalHeartbeat();
3469
3564
  emitOutput(text, "stderr");
3470
3565
  },
3566
+ ...(supportsNativeStructuredOutput
3567
+ ? { outputSchema: desc.outputSchema }
3568
+ : {}),
3471
3569
  });
3472
3570
  const retryText = (schemaRetryResult.text ?? "").trim();
3473
3571
  responseText = retryText || responseText;
@@ -3489,8 +3587,24 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
3489
3587
  cloneJsonValue(schemaRetryMessages) ?? schemaRetryMessages;
3490
3588
  // Try to parse the retry response
3491
3589
  let retryOutput;
3590
+ if (supportsNativeStructuredOutput) {
3591
+ try {
3592
+ if (schemaRetryResult._output !== undefined &&
3593
+ schemaRetryResult._output !== null) {
3594
+ retryOutput = schemaRetryResult._output;
3595
+ }
3596
+ else if (schemaRetryResult.output !== undefined &&
3597
+ schemaRetryResult.output !== null) {
3598
+ retryOutput = schemaRetryResult.output;
3599
+ }
3600
+ }
3601
+ catch {
3602
+ // Structured output access threw; fall back to text parsing.
3603
+ }
3604
+ }
3492
3605
  try {
3493
- if (retryText.startsWith("{") || retryText.startsWith("[")) {
3606
+ if (retryOutput === undefined &&
3607
+ (retryText.startsWith("{") || retryText.startsWith("["))) {
3494
3608
  retryOutput = JSON.parse(retryText);
3495
3609
  }
3496
3610
  }
@@ -3679,6 +3793,30 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
3679
3793
  if (isHeartbeatPayloadValidationError(effectiveError)) {
3680
3794
  attemptMeta.failureRetryable = false;
3681
3795
  }
3796
+ // Allow agents (e.g. BaseCliAgent on "LLM not set") to flag a failure as
3797
+ // non-retryable via SmithersError details. Without this, the engine would
3798
+ // retry deterministic configuration errors up to desc.retries times.
3799
+ if (effectiveError &&
3800
+ typeof effectiveError === "object" &&
3801
+ // @ts-ignore — duck-type on SmithersError shape
3802
+ (effectiveError.details?.failureRetryable === false ||
3803
+ // @ts-ignore
3804
+ effectiveError.code === "AGENT_CONFIG_INVALID")) {
3805
+ attemptMeta.failureRetryable = false;
3806
+ }
3807
+ // Honour `discardResumeSession: true` from agent-side errors (e.g. kimi
3808
+ // session-loss). The next attempt's resumeSession resolution checks
3809
+ // attemptMeta.discardResumeSession on the most recent failed attempt
3810
+ // and clears the captured agentResume so the agent starts fresh
3811
+ // instead of redundantly trying to resume a corrupt session.
3812
+ if (effectiveError &&
3813
+ typeof effectiveError === "object" &&
3814
+ // @ts-ignore — duck-type on SmithersError shape
3815
+ effectiveError.details &&
3816
+ // @ts-ignore
3817
+ effectiveError.details.discardResumeSession === true) {
3818
+ attemptMeta.discardResumeSession = true;
3819
+ }
3682
3820
  if (!heartbeatTimeoutError && (taskSignal.aborted || isAbortError(err))) {
3683
3821
  await waitForHeartbeatWriteDrain();
3684
3822
  await flushHeartbeat(true);
package/src/hot/watch.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { watch } from "node:fs";
2
- import { readdir, stat } from "node:fs/promises";
3
- import { resolve, relative } from "node:path";
2
+ import { readdir } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
4
  import { Effect } from "effect";
5
5
  import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
6
6
  import { logDebug, logInfo } from "@smithers-orchestrator/observability/logging";
package/src/signals.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Effect } from "effect";
2
- import { SmithersDb } from "@smithers-orchestrator/db/adapter";
3
2
  import { bridgeSignalResolve } from "./effect/durable-deferred-bridge.js";
4
3
  import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
5
4
  import { nowMs } from "@smithers-orchestrator/scheduler/nowMs";