experimental-ash 0.21.0 → 0.22.1

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 (60) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/docs/public/hooks.md +39 -6
  3. package/dist/docs/public/skills.md +6 -1
  4. package/dist/src/chunks/{dev-authored-source-watcher-XmRMDVDO.js → dev-authored-source-watcher-Bj0TDis0.js} +1 -1
  5. package/dist/src/chunks/host-BDf7A9G7.js +22 -0
  6. package/dist/src/chunks/paths-DxgjK2PI.js +88 -0
  7. package/dist/src/chunks/{prewarm-Om8D2hpG.js → prewarm-xQtl6kAD.js} +1 -1
  8. package/dist/src/cli/commands/info.js +1 -1
  9. package/dist/src/cli/run.js +1 -1
  10. package/dist/src/compiled/.vendor-stamp.json +3 -3
  11. package/dist/src/compiled/@ai-sdk/anthropic/_provider-utils.d.ts +15 -0
  12. package/dist/src/compiled/@ai-sdk/anthropic/index.d.ts +1259 -6
  13. package/dist/src/compiled/@ai-sdk/google/_provider-utils.d.ts +11 -0
  14. package/dist/src/compiled/@ai-sdk/google/index.d.ts +561 -6
  15. package/dist/src/compiled/@ai-sdk/google/index.js +4 -4
  16. package/dist/src/compiled/@ai-sdk/google/package.json +1 -1
  17. package/dist/src/compiled/@ai-sdk/openai/_provider-utils.d.ts +15 -0
  18. package/dist/src/compiled/@ai-sdk/openai/index.d.ts +1255 -6
  19. package/dist/src/compiled/@ai-sdk/openai/index.js +1 -1
  20. package/dist/src/compiled/@ai-sdk/openai/package.json +1 -1
  21. package/dist/src/compiler/normalize-skill.js +5 -1
  22. package/dist/src/compiler/workspace-resources.js +12 -5
  23. package/dist/src/context/hook-lifecycle.d.ts +8 -8
  24. package/dist/src/context/hook-lifecycle.js +88 -36
  25. package/dist/src/evals/cli/eval.js +1 -1
  26. package/dist/src/execution/skills/instructions.d.ts +8 -4
  27. package/dist/src/execution/skills/instructions.js +3 -2
  28. package/dist/src/harness/action-result-helpers.d.ts +9 -6
  29. package/dist/src/harness/action-result-helpers.js +23 -16
  30. package/dist/src/harness/model-call-error.d.ts +16 -0
  31. package/dist/src/harness/model-call-error.js +71 -0
  32. package/dist/src/harness/provider-tools.d.ts +33 -2
  33. package/dist/src/harness/provider-tools.js +81 -0
  34. package/dist/src/harness/step-hooks.d.ts +21 -0
  35. package/dist/src/harness/step-hooks.js +7 -2
  36. package/dist/src/harness/tool-loop.js +295 -146
  37. package/dist/src/harness/tools.d.ts +12 -0
  38. package/dist/src/harness/tools.js +23 -5
  39. package/dist/src/internal/application/package.js +1 -1
  40. package/dist/src/internal/authored-definition/core.js +16 -1
  41. package/dist/src/internal/nitro/host/build-application.js +65 -1
  42. package/dist/src/internal/workflow-bundle/vercel-workflow-output.d.ts +15 -0
  43. package/dist/src/internal/workflow-bundle/vercel-workflow-output.js +156 -1
  44. package/dist/src/public/definitions/connections/mcp.js +2 -0
  45. package/dist/src/public/definitions/hook.d.ts +11 -3
  46. package/dist/src/public/definitions/skill.d.ts +4 -3
  47. package/dist/src/public/definitions/skill.js +1 -1
  48. package/dist/src/public/definitions/tool.js +2 -0
  49. package/dist/src/public/skills/index.d.ts +1 -1
  50. package/dist/src/public/skills/index.js +1 -1
  51. package/dist/src/public/tool-result-narrowing.d.ts +10 -7
  52. package/dist/src/public/tool-result-narrowing.js +42 -13
  53. package/dist/src/runtime/resolve-connection.js +5 -2
  54. package/dist/src/runtime/resolve-tool.js +5 -2
  55. package/dist/src/shared/skill-definition.d.ts +20 -2
  56. package/dist/src/shared/skill-package.d.ts +43 -0
  57. package/dist/src/shared/skill-package.js +100 -0
  58. package/package.json +3 -3
  59. package/dist/src/chunks/host-CyC15ErG.js +0 -22
  60. package/dist/src/chunks/paths-BgL4fqtv.js +0 -88
@@ -2,8 +2,10 @@ import { getAdapterKind } from "../channel/adapter.js";
2
2
  import { emitRecoverableFailedTurn, emitTurnPreamble, getHarnessEmissionState, setHarnessEmissionState, } from "../harness/emission.js";
3
3
  import { createLogger } from "../internal/logging.js";
4
4
  import { toErrorMessage } from "../shared/errors.js";
5
+ import { normalizeSkillPackage, writeSkillPackageToSandbox, } from "../shared/skill-package.js";
6
+ import { formatAvailableSkillsSection } from "#execution/skills/instructions.js";
5
7
  import { contextStorage } from "./container.js";
6
- import { BundleKey, ChannelKey, ContinuationTokenKey, SessionIdKey, SessionPreparedKey, } from "./keys.js";
8
+ import { BundleKey, ChannelKey, ContinuationTokenKey, SandboxKey, SessionIdKey, SessionPreparedKey, } from "./keys.js";
7
9
  const log = createLogger("hooks.lifecycle");
8
10
  /**
9
11
  * Runs the per-turn hook lifecycle inside the active ALS scope and
@@ -20,11 +22,12 @@ const log = createLogger("hooks.lifecycle");
20
22
  *
21
23
  * 2. `lifecycle.turn` — runs once per fresh delivery (the caller
22
24
  * gates dispatch with `isHarnessBetweenTurns(session)`). Each
23
- * hook may return `{ modelContext }`; contributions are
24
- * concatenated in registry order. A thrown hook is caught here,
25
- * the recoverable `turn.failed` cascade is emitted, and the
26
- * dispatcher returns `kind: "turn-failed"` so the caller can park
27
- * the session.
25
+ * hook may return `{ modelContext, skills }`; model-context
26
+ * contributions are concatenated in registry order and skills are
27
+ * materialized into the live sandbox before the model call. A
28
+ * thrown hook is caught here, the recoverable `turn.failed`
29
+ * cascade is emitted, and the dispatcher returns
30
+ * `kind: "turn-failed"` so the caller can park the session.
28
31
  *
29
32
  * Errors:
30
33
  *
@@ -48,56 +51,63 @@ export async function dispatchHookLifecycle(input) {
48
51
  turn: { sequence, turnId },
49
52
  };
50
53
  const modelContext = [];
54
+ let nextSession = input.session;
51
55
  // ---------- lifecycle.session ----------
52
56
  if (registry.session.length > 0 && ctx.get(SessionPreparedKey) !== true) {
53
57
  // Set the flag BEFORE running the chain. A thrown session hook is
54
58
  // a terminal session failure; the next turn must not retry.
55
59
  ctx.set(SessionPreparedKey, true);
60
+ const stageSkills = [];
56
61
  for (const entry of registry.session) {
57
62
  const result = (await entry.handler(lifecycleInput, hookCtx));
58
63
  if (result?.modelContext !== undefined && result.modelContext.length > 0) {
59
64
  modelContext.push(...result.modelContext);
60
65
  }
66
+ stageSkills.push(...normalizeLifecycleSkillResults(ctx, result));
61
67
  }
68
+ nextSession = await materializeLifecycleSkills(ctx, nextSession, stageSkills);
62
69
  }
63
70
  // ---------- lifecycle.turn ----------
64
71
  let preambleEmitted = false;
65
72
  let preambleState = emissionState;
66
- for (const entry of registry.turn) {
67
- try {
73
+ try {
74
+ const stageSkills = [];
75
+ for (const entry of registry.turn) {
68
76
  const result = (await entry.handler(lifecycleInput, hookCtx));
69
77
  if (result?.modelContext !== undefined && result.modelContext.length > 0) {
70
78
  modelContext.push(...result.modelContext);
71
79
  }
80
+ stageSkills.push(...normalizeLifecycleSkillResults(ctx, result));
72
81
  }
73
- catch (error) {
74
- const message = toErrorMessage(error);
75
- try {
76
- if (!preambleEmitted) {
77
- preambleState = await emitTurnPreamble(emit, { message: input.input.message }, emissionState, input.runtimeIdentity);
78
- preambleEmitted = true;
79
- }
80
- const failedState = await emitRecoverableFailedTurn(emit, preambleState, {
81
- code: "HOOK_TURN_FAILED",
82
- message,
83
- });
84
- return {
85
- kind: "turn-failed",
86
- message,
87
- nextSession: setHarnessEmissionState(input.session, failedState),
88
- };
89
- }
90
- catch (cascadeError) {
91
- // An event hook subscribed to one of the failure-cascade
92
- // events (eg. `events["turn.failed"]`) threw while we were
93
- // emitting the recoverable failure. Re-throwing escalates to
94
- // a terminal `session.failed` via the runtime's outer error
95
- // path. Document this as the bounded second-order behavior:
96
- // when both a `lifecycle.turn` and a `turn.failed` event hook
97
- // throw, the session ends.
98
- log.error(`Event hook threw while emitting the turn.failed cascade for hook "${entry.slug}" — escalating to session.failed.`, { error: toErrorMessage(cascadeError) });
99
- throw cascadeError;
82
+ nextSession = await materializeLifecycleSkills(ctx, nextSession, stageSkills);
83
+ }
84
+ catch (error) {
85
+ const message = toErrorMessage(error);
86
+ try {
87
+ if (!preambleEmitted) {
88
+ preambleState = await emitTurnPreamble(emit, { message: input.input.message }, emissionState, input.runtimeIdentity);
89
+ preambleEmitted = true;
100
90
  }
91
+ const failedState = await emitRecoverableFailedTurn(emit, preambleState, {
92
+ code: "HOOK_TURN_FAILED",
93
+ message,
94
+ });
95
+ return {
96
+ kind: "turn-failed",
97
+ message,
98
+ nextSession: setHarnessEmissionState(nextSession, failedState),
99
+ };
100
+ }
101
+ catch (cascadeError) {
102
+ // An event hook subscribed to one of the failure-cascade events
103
+ // (eg. `events["turn.failed"]`) threw while we were emitting the
104
+ // recoverable failure. Re-throwing escalates to a terminal
105
+ // `session.failed` via the runtime's outer error path. Document
106
+ // this as the bounded second-order behavior: when both a
107
+ // `lifecycle.turn` and a `turn.failed` event hook throw, the
108
+ // session ends.
109
+ log.error("Event hook threw while emitting the turn.failed cascade for a lifecycle.turn failure — escalating to session.failed.", { error: toErrorMessage(cascadeError) });
110
+ throw cascadeError;
101
111
  }
102
112
  }
103
113
  const channelContext = input.input.modelContext ?? [];
@@ -107,7 +117,49 @@ export async function dispatchHookLifecycle(input) {
107
117
  input: mergedModelContext.length > 0
108
118
  ? { ...input.input, modelContext: mergedModelContext }
109
119
  : input.input,
110
- nextSession: input.session,
120
+ nextSession,
121
+ };
122
+ }
123
+ function normalizeLifecycleSkillResults(ctx, result) {
124
+ if (result?.skills === undefined || result.skills.length === 0) {
125
+ return [];
126
+ }
127
+ const authoredSkillNames = new Set(ctx.require(BundleKey).resolvedAgent.skills.map((skill) => skill.name));
128
+ const dynamicSkills = new Map();
129
+ for (const skill of result.skills) {
130
+ const normalized = normalizeSkillPackage(skill);
131
+ if (authoredSkillNames.has(normalized.name)) {
132
+ throw new Error(`Hook-contributed skill "${normalized.name}" conflicts with an authored skill.`);
133
+ }
134
+ dynamicSkills.set(normalized.name, normalized);
135
+ }
136
+ return [...dynamicSkills.values()];
137
+ }
138
+ async function materializeLifecycleSkills(ctx, session, skills) {
139
+ if (skills.length === 0) {
140
+ return session;
141
+ }
142
+ const dedupedSkills = new Map(skills.map((skill) => [skill.name, skill]));
143
+ const sandbox = await ctx.require(SandboxKey).get();
144
+ if (sandbox === null) {
145
+ throw new Error("Dynamic skills require a sandbox for the current agent.");
146
+ }
147
+ for (const skill of dedupedSkills.values()) {
148
+ await writeSkillPackageToSandbox({ sandbox, skill });
149
+ }
150
+ const announcement = formatAvailableSkillsSection([...dedupedSkills.values()]);
151
+ if (announcement === null) {
152
+ return session;
153
+ }
154
+ return {
155
+ ...session,
156
+ history: [
157
+ ...session.history,
158
+ {
159
+ role: "system",
160
+ content: announcement,
161
+ },
162
+ ],
111
163
  };
112
164
  }
113
165
  /**
@@ -1 +1 @@
1
- import{n as e}from"../../chunks/paths-BgL4fqtv.js";import{loadDevelopmentEnvironmentFiles as t}from"../../cli/dev/environment.js";import{a as n,n as r,t as i}from"../../chunks/client-CKsU8Li3.js";import{n as a}from"../../chunks/host-CyC15ErG.js";import{discoverAndImportSuites as o,discoverSuiteFiles as s,importSuiteFile as c}from"../runner/discover.js";import{executeSuite as l}from"../runner/execute-suite.js";import{ConsoleReporter as u}from"../runner/reporters/console.js";var d=n();function f(e,t){e.command(`eval`).description(`Run eval suites against an Ash agent.`).option(`--suite <id...>`,`Suite IDs to run (repeatable)`).option(`--all`,`Run all discovered suites`).option(`--url <url>`,`Remote agent URL (skip local host startup)`).option(`--timeout <ms>`,`Per-case timeout in milliseconds`).option(`--max-concurrency <n>`,`Max concurrent case executions per suite`).option(`--json`,`Output results as JSON`).option(`--list-suites`,`List discovered suites and exit`).option(`--skip-report`,`Skip suite-defined reporters (e.g. Braintrust)`).action(async e=>{await p(e,t)})}async function p(n,r){let i=e();if(t(i),n.listSuites){await y(i,r);return}let s=n.suite,c=await o(i,s);if(c.length===0){s&&s.length>0?r.error(`No suites found matching: ${s.join(`, `)}`):r.error(`No eval suites found. Create suite files under evals/ with the *.eval.ts extension.`),process.exitCode=1;return}let u,d;n.url?d={kind:`remote`,url:n.url}:(u=await a(i,{host:`127.0.0.1`,port:0}),d={kind:`local`,url:u.url});let f=m(d);try{let e=[];for(let t of c){let r=_(t,n),a=v(r,{json:n.json===!0,skipReport:n.skipReport===!0}),o=await l({suite:r,target:d,reporters:a,appRoot:i,client:f});e.push(o)}n.json&&r.log(JSON.stringify(e,null,2)),e.some(e=>e.errored>0)&&(process.exitCode=1)}finally{u&&await u.close()}process.exit(process.exitCode??0)}function m(e){if(e.kind===`local`)return new i({host:e.url});let t={},n=process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim();return n&&(t[r]=n),new i({auth:h(),headers:Object.keys(t).length>0?t:void 0,host:e.url})}function h(){let e=process.env.ASH_EVAL_AUTH_TOKEN?.trim();return e?{bearer:e}:{bearer:g}}async function g(){try{let e=(await(0,d.getVercelOidcToken)()).trim();if(e.length>0)return e}catch{}return process.env.VERCEL_OIDC_TOKEN?.trim()??``}function _(e,t){let n=t.maxConcurrency?Number.parseInt(t.maxConcurrency,10):void 0,r=t.timeout?Number.parseInt(t.timeout,10):void 0;if(n===void 0&&r===void 0)return e;let i={...e};return n!==void 0&&(i.maxConcurrency=n),r!==void 0&&(i.timeoutMs=r),i}function v(e,t){let n=t.json?[]:[new u];return!t.skipReport&&e.reporters&&n.push(...e.reporters),n}async function y(e,t){let n=await s(e);if(n.length===0){t.log(`No eval suites found.`);return}t.log(`Found ${n.length} eval suite file(s):\n`);for(let r of n){let n=await c(e,r);t.log(` ${n.id}${n.description?` - ${n.description}`:``}`)}}export{f as registerEvalCommand,p as runEvalCommand};
1
+ import{n as e}from"../../chunks/paths-DxgjK2PI.js";import{loadDevelopmentEnvironmentFiles as t}from"../../cli/dev/environment.js";import{a as n,n as r,t as i}from"../../chunks/client-CKsU8Li3.js";import{n as a}from"../../chunks/host-BDf7A9G7.js";import{discoverAndImportSuites as o,discoverSuiteFiles as s,importSuiteFile as c}from"../runner/discover.js";import{executeSuite as l}from"../runner/execute-suite.js";import{ConsoleReporter as u}from"../runner/reporters/console.js";var d=n();function f(e,t){e.command(`eval`).description(`Run eval suites against an Ash agent.`).option(`--suite <id...>`,`Suite IDs to run (repeatable)`).option(`--all`,`Run all discovered suites`).option(`--url <url>`,`Remote agent URL (skip local host startup)`).option(`--timeout <ms>`,`Per-case timeout in milliseconds`).option(`--max-concurrency <n>`,`Max concurrent case executions per suite`).option(`--json`,`Output results as JSON`).option(`--list-suites`,`List discovered suites and exit`).option(`--skip-report`,`Skip suite-defined reporters (e.g. Braintrust)`).action(async e=>{await p(e,t)})}async function p(n,r){let i=e();if(t(i),n.listSuites){await y(i,r);return}let s=n.suite,c=await o(i,s);if(c.length===0){s&&s.length>0?r.error(`No suites found matching: ${s.join(`, `)}`):r.error(`No eval suites found. Create suite files under evals/ with the *.eval.ts extension.`),process.exitCode=1;return}let u,d;n.url?d={kind:`remote`,url:n.url}:(u=await a(i,{host:`127.0.0.1`,port:0}),d={kind:`local`,url:u.url});let f=m(d);try{let e=[];for(let t of c){let r=_(t,n),a=v(r,{json:n.json===!0,skipReport:n.skipReport===!0}),o=await l({suite:r,target:d,reporters:a,appRoot:i,client:f});e.push(o)}n.json&&r.log(JSON.stringify(e,null,2)),e.some(e=>e.errored>0)&&(process.exitCode=1)}finally{u&&await u.close()}process.exit(process.exitCode??0)}function m(e){if(e.kind===`local`)return new i({host:e.url});let t={},n=process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim();return n&&(t[r]=n),new i({auth:h(),headers:Object.keys(t).length>0?t:void 0,host:e.url})}function h(){let e=process.env.ASH_EVAL_AUTH_TOKEN?.trim();return e?{bearer:e}:{bearer:g}}async function g(){try{let e=(await(0,d.getVercelOidcToken)()).trim();if(e.length>0)return e}catch{}return process.env.VERCEL_OIDC_TOKEN?.trim()??``}function _(e,t){let n=t.maxConcurrency?Number.parseInt(t.maxConcurrency,10):void 0,r=t.timeout?Number.parseInt(t.timeout,10):void 0;if(n===void 0&&r===void 0)return e;let i={...e};return n!==void 0&&(i.maxConcurrency=n),r!==void 0&&(i.timeoutMs=r),i}function v(e,t){let n=t.json?[]:[new u];return!t.skipReport&&e.reporters&&n.push(...e.reporters),n}async function y(e,t){let n=await s(e);if(n.length===0){t.log(`No eval suites found.`);return}t.log(`Found ${n.length} eval suite file(s):\n`);for(let r of n){let n=await c(e,r);t.log(` ${n.id}${n.description?` - ${n.description}`:``}`)}}export{f as registerEvalCommand,p as runEvalCommand};
@@ -1,4 +1,7 @@
1
- import type { ResolvedSkillDefinition } from "#runtime/types.js";
1
+ export interface AvailableSkillDescription {
2
+ readonly description: string;
3
+ readonly name: string;
4
+ }
2
5
  /**
3
6
  * Formats the "Available skills" system prompt section.
4
7
  *
@@ -7,7 +10,8 @@ import type { ResolvedSkillDefinition } from "#runtime/types.js";
7
10
  * has them from the `load_skill` tool result. This keeps the system
8
11
  * prompt identical across the entire session, preserving prompt caching.
9
12
  *
10
- * Called at graph resolution time so the section is part of the turn
11
- * agent's static instructions rather than injected per-step.
13
+ * Authored skills call this at graph resolution time so the section is
14
+ * part of the turn agent's static instructions. Hook-contributed skills
15
+ * reuse the same formatter for durable history announcements.
12
16
  */
13
- export declare function formatAvailableSkillsSection(skills: readonly ResolvedSkillDefinition[]): string | null;
17
+ export declare function formatAvailableSkillsSection(skills: readonly AvailableSkillDescription[]): string | null;
@@ -7,8 +7,9 @@ import { WORKSPACE_ROOT } from "#runtime/workspace/types.js";
7
7
  * has them from the `load_skill` tool result. This keeps the system
8
8
  * prompt identical across the entire session, preserving prompt caching.
9
9
  *
10
- * Called at graph resolution time so the section is part of the turn
11
- * agent's static instructions rather than injected per-step.
10
+ * Authored skills call this at graph resolution time so the section is
11
+ * part of the turn agent's static instructions. Hook-contributed skills
12
+ * reuse the same formatter for durable history announcements.
12
13
  */
13
14
  export function formatAvailableSkillsSection(skills) {
14
15
  if (skills.length === 0) {
@@ -1,5 +1,6 @@
1
1
  import type { ModelMessage, ToolSet, TypedToolResult } from "ai";
2
2
  import type { RuntimeToolResultActionResult } from "#runtime/actions/types.js";
3
+ import type { JsonValue } from "#shared/json.js";
3
4
  type ToolResponsePart = Extract<ModelMessage, {
4
5
  role: "tool";
5
6
  }>["content"][number];
@@ -7,14 +8,16 @@ type ToolResultPart = Extract<ToolResponsePart, {
7
8
  type: "tool-result";
8
9
  }>;
9
10
  /**
10
- * Serializes an arbitrary tool result payload to a stable string form.
11
+ * Coerces an arbitrary value to a JSON-safe {@link JsonValue} without
12
+ * premature stringification.
11
13
  *
12
- * String inputs pass through. `Error` instances surface their message so
13
- * stack traces never leak into protocol events. Everything else is
14
- * `JSON.stringify`'d, with a `String(value)` fallback for values JSON
15
- * cannot represent (functions, symbols, BigInts).
14
+ * - Strings, numbers, booleans, and `null` pass through as primitives.
15
+ * - `Error` instances surface only their message (no stack leak).
16
+ * - Plain objects and arrays pass through structurally.
17
+ * - Non-JSON-representable values (functions, symbols, BigInts) fall
18
+ * back to `String(value)`.
16
19
  */
17
- export declare function serializeActionOutput(value: unknown): string;
20
+ export declare function toJsonValue(value: unknown): JsonValue;
18
21
  /**
19
22
  * Builds a `RuntimeToolResultActionResult` from one AI SDK
20
23
  * {@link TypedToolResult}. Used for tool results captured on the AI SDK
@@ -1,20 +1,27 @@
1
1
  /**
2
- * Serializes an arbitrary tool result payload to a stable string form.
2
+ * Coerces an arbitrary value to a JSON-safe {@link JsonValue} without
3
+ * premature stringification.
3
4
  *
4
- * String inputs pass through. `Error` instances surface their message so
5
- * stack traces never leak into protocol events. Everything else is
6
- * `JSON.stringify`'d, with a `String(value)` fallback for values JSON
7
- * cannot represent (functions, symbols, BigInts).
5
+ * - Strings, numbers, booleans, and `null` pass through as primitives.
6
+ * - `Error` instances surface only their message (no stack leak).
7
+ * - Plain objects and arrays pass through structurally.
8
+ * - Non-JSON-representable values (functions, symbols, BigInts) fall
9
+ * back to `String(value)`.
8
10
  */
9
- export function serializeActionOutput(value) {
10
- if (typeof value === "string") {
11
+ export function toJsonValue(value) {
12
+ if (value === null ||
13
+ typeof value === "string" ||
14
+ typeof value === "number" ||
15
+ typeof value === "boolean") {
11
16
  return value;
12
17
  }
13
18
  if (value instanceof Error) {
14
19
  return value.message;
15
20
  }
16
- const serialized = JSON.stringify(value);
17
- return serialized === undefined ? String(value) : serialized;
21
+ if (typeof value === "object") {
22
+ return value;
23
+ }
24
+ return String(value);
18
25
  }
19
26
  /**
20
27
  * Builds a `RuntimeToolResultActionResult` from one AI SDK
@@ -25,7 +32,7 @@ export function createRuntimeToolResultFromStepResult(toolResult) {
25
32
  return {
26
33
  callId: toolResult.toolCallId,
27
34
  kind: "tool-result",
28
- output: serializeActionOutput(toolResult.output),
35
+ output: toJsonValue(toolResult.output),
29
36
  toolName: toolResult.toolName,
30
37
  };
31
38
  }
@@ -40,7 +47,7 @@ export function createRuntimeToolResultFromMessagePart(part) {
40
47
  const result = {
41
48
  callId: part.toolCallId,
42
49
  kind: "tool-result",
43
- output: serializeToolResultOutput(part.output),
50
+ output: toolResultOutputToJsonValue(part.output),
44
51
  toolName: part.toolName,
45
52
  };
46
53
  if (isToolResultError(part.output)) {
@@ -51,21 +58,21 @@ export function createRuntimeToolResultFromMessagePart(part) {
51
58
  }
52
59
  return result;
53
60
  }
54
- function serializeToolResultOutput(output) {
61
+ function toolResultOutputToJsonValue(output) {
55
62
  switch (output.type) {
56
63
  case "text":
57
64
  case "error-text":
58
65
  return output.value;
59
66
  case "json":
60
67
  case "error-json":
61
- return serializeActionOutput(output.value);
68
+ return toJsonValue(output.value);
62
69
  case "execution-denied":
63
- return serializeActionOutput({
70
+ return {
64
71
  code: "TOOL_EXECUTION_DENIED",
65
72
  message: output.reason ?? "Tool execution was denied.",
66
- });
73
+ };
67
74
  case "content":
68
- return serializeActionOutput(output.value);
75
+ return toJsonValue(output.value);
69
76
  }
70
77
  }
71
78
  function isToolResultError(output) {
@@ -24,6 +24,22 @@ export declare function summarizeKnownModelCallConfigError(error: unknown): Mode
24
24
  * response, so the user-facing message should avoid implying a bad tool call.
25
25
  */
26
26
  export declare function summarizeKnownModelCallRequestError(error: unknown): ModelCallConfigErrorSummary | null;
27
+ /**
28
+ * Returns the distinct upstream tool types referenced by any
29
+ * "tool type 'X' is not supported" rejection in an AI Gateway error's
30
+ * provider attempt list.
31
+ *
32
+ * Walks the cause chain to find the gateway error and inspects both the
33
+ * structured `data` field and the raw `responseBody` JSON. Returns an
34
+ * empty array for errors that are not of this shape.
35
+ *
36
+ * Used by the harness recovery path to identify which framework tools
37
+ * to drop before retrying the failing step. Detection is by string
38
+ * match on the upstream tool type — see
39
+ * {@link resolveFrameworkToolFromUpstreamType} for the mapping back to
40
+ * framework tool names.
41
+ */
42
+ export declare function extractUnsupportedProviderToolTypes(error: unknown): readonly string[];
27
43
  /**
28
44
  * Extracts compact, structured diagnostics from AI SDK / AI Gateway model-call
29
45
  * errors. The full SDK error can include very large request bodies (especially
@@ -1,6 +1,17 @@
1
1
  import { isObject } from "#shared/guards.js";
2
2
  const RESPONSE_BODY_SNIPPET_LIMIT = 1_000;
3
3
  const GATEWAY_MODEL_REQUEST_REJECTED_MESSAGE = "AI Gateway rejected the model request before the agent produced a response.";
4
+ /**
5
+ * Anchored regex for the upstream "unsupported tool" rejection message
6
+ * that AI Gateway returns when a fallback provider cannot serve a
7
+ * provider-specific tool (e.g. Bedrock rejecting `web_search_20250305`).
8
+ *
9
+ * The phrasing comes from the gateway's own provider attempt projection
10
+ * and is stable across the Bedrock and Vertex Anthropic backends. We
11
+ * anchor the match on the literal `tool type` prefix to avoid sweeping
12
+ * in unrelated "not supported" errors.
13
+ */
14
+ const UNSUPPORTED_TOOL_TYPE_REGEX = /tool type ['"]([\w.-]+)['"] is not supported/i;
4
15
  /**
5
16
  * Returns a concise actionable summary for known terminal configuration
6
17
  * errors raised during a model call. Returns `null` for everything else
@@ -56,6 +67,66 @@ export function summarizeKnownModelCallRequestError(error) {
56
67
  }
57
68
  return null;
58
69
  }
70
+ /**
71
+ * Returns the distinct upstream tool types referenced by any
72
+ * "tool type 'X' is not supported" rejection in an AI Gateway error's
73
+ * provider attempt list.
74
+ *
75
+ * Walks the cause chain to find the gateway error and inspects both the
76
+ * structured `data` field and the raw `responseBody` JSON. Returns an
77
+ * empty array for errors that are not of this shape.
78
+ *
79
+ * Used by the harness recovery path to identify which framework tools
80
+ * to drop before retrying the failing step. Detection is by string
81
+ * match on the upstream tool type — see
82
+ * {@link resolveFrameworkToolFromUpstreamType} for the mapping back to
83
+ * framework tool names.
84
+ */
85
+ export function extractUnsupportedProviderToolTypes(error) {
86
+ const found = new Set();
87
+ for (const candidate of walkCauseChain(error)) {
88
+ collectUnsupportedToolTypesFromValue(readObjectField(candidate, "data"), found);
89
+ const responseBody = readStringField(candidate, "responseBody");
90
+ if (responseBody !== undefined) {
91
+ try {
92
+ collectUnsupportedToolTypesFromValue(JSON.parse(responseBody), found);
93
+ }
94
+ catch {
95
+ // The response body may be truncated mid-JSON when the upstream
96
+ // includes a large request snapshot. Fall back to a raw string
97
+ // scan so we still surface the tool name when the regex match
98
+ // lies before the truncation boundary.
99
+ const match = UNSUPPORTED_TOOL_TYPE_REGEX.exec(responseBody);
100
+ if (match?.[1] !== undefined) {
101
+ found.add(match[1]);
102
+ }
103
+ }
104
+ }
105
+ }
106
+ return [...found];
107
+ }
108
+ function collectUnsupportedToolTypesFromValue(value, out) {
109
+ if (value === null || value === undefined)
110
+ return;
111
+ if (typeof value === "string") {
112
+ const match = UNSUPPORTED_TOOL_TYPE_REGEX.exec(value);
113
+ if (match?.[1] !== undefined) {
114
+ out.add(match[1]);
115
+ }
116
+ return;
117
+ }
118
+ if (Array.isArray(value)) {
119
+ for (const entry of value) {
120
+ collectUnsupportedToolTypesFromValue(entry, out);
121
+ }
122
+ return;
123
+ }
124
+ if (isObject(value)) {
125
+ for (const entry of Object.values(value)) {
126
+ collectUnsupportedToolTypesFromValue(entry, out);
127
+ }
128
+ }
129
+ }
59
130
  /**
60
131
  * Extracts compact, structured diagnostics from AI SDK / AI Gateway model-call
61
132
  * errors. The full SDK error can include very large request bodies (especially
@@ -3,7 +3,39 @@ import type { RuntimeModelReference } from "#runtime/agent/bootstrap.js";
3
3
  /**
4
4
  * The provider backend resolved for one web search tool invocation.
5
5
  */
6
- type WebSearchBackend = "anthropic" | "gateway" | "google" | "openai";
6
+ export type WebSearchBackend = "anthropic" | "gateway" | "google" | "openai";
7
+ /**
8
+ * Returns the framework tool name that produced an upstream provider tool
9
+ * `type`, or `null` when the type is not one we know how to remove.
10
+ *
11
+ * Used by the harness recovery path to decide which tools to drop when a
12
+ * gateway fallback provider rejects a tool. Unknown types fall through to
13
+ * the existing terminal/recoverable handling.
14
+ */
15
+ export declare function resolveFrameworkToolFromUpstreamType(type: string): string | null;
16
+ /**
17
+ * Maps a {@link WebSearchBackend} to the gateway provider slug used in
18
+ * `providerOptions.gateway.only` to pin routing to that provider.
19
+ *
20
+ * Returns `null` for the `"gateway"` backend (Perplexity via AI Gateway),
21
+ * which is served by the gateway directly and does not need pinning.
22
+ */
23
+ export declare function resolveGatewayPinForWebSearchBackend(backend: WebSearchBackend): string | null;
24
+ /**
25
+ * Returns a new `providerOptions` object with
26
+ * `gateway.only = [provider]` merged into the existing `gateway`
27
+ * sub-object so the AI Gateway only attempts the given provider.
28
+ *
29
+ * Used by the harness to pin routing when a provider-specific tool
30
+ * (e.g. Anthropic's `web_search_20250305`) is in the per-step toolset,
31
+ * so a transient primary outage produces a clean retryable 503 instead
32
+ * of a fallback-to-incompatible-provider 400.
33
+ *
34
+ * Author overrides win — if `base.gateway.only` or `base.gateway.order`
35
+ * is already set, the input is returned unchanged so explicit routing
36
+ * preferences are never silently overwritten.
37
+ */
38
+ export declare function mergeGatewayProviderPin(base: Readonly<Record<string, unknown>> | undefined, provider: string): Record<string, unknown>;
7
39
  /**
8
40
  * Determines the web search backend for a model reference.
9
41
  *
@@ -22,4 +54,3 @@ export declare function resolveWebSearchBackend(modelRef: RuntimeModelReference)
22
54
  * provider matching the current model is loaded.
23
55
  */
24
56
  export declare function resolveWebSearchProviderTool(backend: WebSearchBackend): Promise<ToolSet[string]>;
25
- export {};
@@ -1,3 +1,84 @@
1
+ import { WEB_SEARCH_TOOL_DEFINITION } from "#runtime/framework-tools/web-search.js";
2
+ import { isObject } from "#shared/guards.js";
3
+ /**
4
+ * Maps an upstream provider tool type (the literal `type` string the AI SDK
5
+ * sends to the provider) back to the framework tool name that injected it.
6
+ *
7
+ * Used when the AI Gateway routes a request to a fallback provider that
8
+ * does not support a provider-specific tool — the upstream error references
9
+ * the provider-specific type (e.g. `web_search_20250305`), but the harness
10
+ * needs to drop the framework tool by its public name (`web_search`).
11
+ *
12
+ * Adding a new provider tool requires adding the corresponding mapping
13
+ * entry here alongside its {@link resolveWebSearchProviderTool} switch
14
+ * arm so detection stays in lockstep with injection.
15
+ */
16
+ const UPSTREAM_TOOL_TYPE_TO_FRAMEWORK_NAME = {
17
+ // Anthropic's stable web search tool. The Bedrock and Vertex
18
+ // Anthropic backends reject this type because they only host the
19
+ // older Claude Messages surface.
20
+ web_search_20250305: WEB_SEARCH_TOOL_DEFINITION.name,
21
+ };
22
+ /**
23
+ * Returns the framework tool name that produced an upstream provider tool
24
+ * `type`, or `null` when the type is not one we know how to remove.
25
+ *
26
+ * Used by the harness recovery path to decide which tools to drop when a
27
+ * gateway fallback provider rejects a tool. Unknown types fall through to
28
+ * the existing terminal/recoverable handling.
29
+ */
30
+ export function resolveFrameworkToolFromUpstreamType(type) {
31
+ return UPSTREAM_TOOL_TYPE_TO_FRAMEWORK_NAME[type] ?? null;
32
+ }
33
+ /**
34
+ * Maps a {@link WebSearchBackend} to the gateway provider slug used in
35
+ * `providerOptions.gateway.only` to pin routing to that provider.
36
+ *
37
+ * Returns `null` for the `"gateway"` backend (Perplexity via AI Gateway),
38
+ * which is served by the gateway directly and does not need pinning.
39
+ */
40
+ export function resolveGatewayPinForWebSearchBackend(backend) {
41
+ switch (backend) {
42
+ case "anthropic":
43
+ return "anthropic";
44
+ case "openai":
45
+ return "openai";
46
+ case "google":
47
+ return "google";
48
+ case "gateway":
49
+ return null;
50
+ }
51
+ }
52
+ /**
53
+ * Returns a new `providerOptions` object with
54
+ * `gateway.only = [provider]` merged into the existing `gateway`
55
+ * sub-object so the AI Gateway only attempts the given provider.
56
+ *
57
+ * Used by the harness to pin routing when a provider-specific tool
58
+ * (e.g. Anthropic's `web_search_20250305`) is in the per-step toolset,
59
+ * so a transient primary outage produces a clean retryable 503 instead
60
+ * of a fallback-to-incompatible-provider 400.
61
+ *
62
+ * Author overrides win — if `base.gateway.only` or `base.gateway.order`
63
+ * is already set, the input is returned unchanged so explicit routing
64
+ * preferences are never silently overwritten.
65
+ */
66
+ export function mergeGatewayProviderPin(base, provider) {
67
+ const baseGateway = isObject(base?.gateway)
68
+ ? base.gateway
69
+ : undefined;
70
+ if (baseGateway?.only !== undefined || baseGateway?.order !== undefined) {
71
+ return { ...base };
72
+ }
73
+ const mergedGateway = {
74
+ ...baseGateway,
75
+ only: [provider],
76
+ };
77
+ return {
78
+ ...base,
79
+ gateway: mergedGateway,
80
+ };
81
+ }
1
82
  /**
2
83
  * Determines the web search backend for a model reference.
3
84
  *
@@ -16,6 +16,27 @@ export interface StepHooksInput {
16
16
  readonly cachePath: PromptCachePath;
17
17
  readonly emit?: HarnessEmitFn;
18
18
  readonly emissionState: HarnessEmissionState;
19
+ /**
20
+ * When `false`, `prepareStep` skips the `step.started` emission.
21
+ * Used by the harness recovery path to avoid emitting `step.started`
22
+ * twice when retrying the same step with a degraded toolset.
23
+ *
24
+ * Defaults to `true`.
25
+ */
26
+ readonly emitStepStarted?: boolean;
27
+ /**
28
+ * When set on the `gateway-auto` cache path, merges
29
+ * `providerOptions.gateway.only = [gatewayPinProvider]` so the AI
30
+ * Gateway only routes to the given provider. Used to keep
31
+ * provider-specific tools (e.g. Anthropic's `web_search_20250305`)
32
+ * on a provider that can serve them, converting a transient outage
33
+ * into a clean retryable 503 rather than a fallback-to-incompatible
34
+ * provider 400.
35
+ *
36
+ * Ignored when the author already set `gateway.only` or
37
+ * `gateway.order` on the model reference's provider options.
38
+ */
39
+ readonly gatewayPinProvider?: string;
19
40
  readonly marker: AnthropicCacheMarker | undefined;
20
41
  readonly session: HarnessSession;
21
42
  }
@@ -3,6 +3,7 @@ import { createRuntimeToolResultFromMessagePart, createRuntimeToolResultFromStep
3
3
  import { emitStepStarted, normalizeAssistantStepFinishReason } from "#harness/emission.js";
4
4
  import { extractToolApprovalInputRequests } from "#harness/input-extraction.js";
5
5
  import { applyConversationCacheControl, mergeGatewayAutoCaching, } from "#harness/prompt-cache.js";
6
+ import { mergeGatewayProviderPin } from "#harness/provider-tools.js";
6
7
  import { createRuntimeActionRequestFromToolCall } from "#harness/runtime-actions.js";
7
8
  // ---------------------------------------------------------------------------
8
9
  // Builder
@@ -31,7 +32,7 @@ export function buildStepHooks(input) {
31
32
  // -------------------------------------------------------------------------
32
33
  const prepareStep = async ({ messages }) => {
33
34
  let processed = messages;
34
- if (emit) {
35
+ if (emit && input.emitStepStarted !== false) {
35
36
  await emitStepStarted(emit, input.emissionState);
36
37
  }
37
38
  if (input.cachePath.kind === "anthropic-direct" && input.marker) {
@@ -41,7 +42,11 @@ export function buildStepHooks(input) {
41
42
  messages: processed,
42
43
  };
43
44
  if (input.cachePath.kind === "gateway-auto") {
44
- stepResult.providerOptions = mergeGatewayAutoCaching(session.agent.modelReference.providerOptions);
45
+ let providerOptions = mergeGatewayAutoCaching(session.agent.modelReference.providerOptions);
46
+ if (input.gatewayPinProvider !== undefined) {
47
+ providerOptions = mergeGatewayProviderPin(providerOptions, input.gatewayPinProvider);
48
+ }
49
+ stepResult.providerOptions = providerOptions;
45
50
  }
46
51
  return stepResult;
47
52
  };