@nylorun/harness 0.9.0-beta.1 → 0.11.1-beta

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 (46) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +5 -79
  3. package/dist/build/bind-tool.js +4 -3
  4. package/dist/build/helpers.d.ts +2 -2
  5. package/dist/build/schema.d.ts +11 -16
  6. package/dist/build/schema.js +141 -22
  7. package/dist/errors.d.ts +1 -1
  8. package/dist/index.d.ts +6 -3
  9. package/dist/index.js +2 -0
  10. package/dist/model/adapters.d.ts +48 -5
  11. package/dist/model/adapters.js +142 -17
  12. package/dist/model/normalize.js +49 -6
  13. package/dist/model/prepared.d.ts +16 -0
  14. package/dist/model/prepared.js +11 -0
  15. package/dist/session/event-log.d.ts +4 -3
  16. package/dist/session/input-queue.d.ts +7 -4
  17. package/dist/session/input-queue.js +12 -0
  18. package/dist/session/output-contract.d.ts +6 -0
  19. package/dist/session/output-contract.js +12 -0
  20. package/dist/session/scheduler.js +1 -1
  21. package/dist/session/seed.js +80 -6
  22. package/dist/session/session.d.ts +8 -5
  23. package/dist/session/session.js +38 -2
  24. package/dist/session/state.d.ts +1 -1
  25. package/dist/session/submission-stream.d.ts +5 -4
  26. package/dist/step/canonicalize.js +3 -0
  27. package/dist/step/context-draft.js +1 -7
  28. package/dist/step/model-configuration.js +5 -19
  29. package/dist/step/project.js +31 -5
  30. package/dist/step/resolve.d.ts +1 -0
  31. package/dist/step/resolve.js +1 -0
  32. package/dist/step/run.d.ts +1 -0
  33. package/dist/step/run.js +24 -3
  34. package/dist/step/seal.d.ts +4 -2
  35. package/dist/step/seal.js +63 -12
  36. package/dist/turn/plan-runner.js +38 -3
  37. package/dist/turn/runner.d.ts +6 -4
  38. package/dist/turn/runner.js +6 -4
  39. package/dist/types/middleware.d.ts +1 -1
  40. package/dist/types/model.d.ts +30 -13
  41. package/dist/types/session.d.ts +34 -13
  42. package/dist/types/shared.d.ts +16 -3
  43. package/dist/types/tool.d.ts +54 -17
  44. package/package.json +11 -12
  45. package/dist/utils/digest.d.ts +0 -1
  46. package/dist/utils/digest.js +0 -14
@@ -1,6 +1,8 @@
1
+ import { HarnessError } from "../errors.js";
1
2
  import { SessionScheduler } from "./scheduler.js";
2
- import { copyJsonObject } from "../utils/immutable.js";
3
+ import { copyJson, copyJsonObject } from "../utils/immutable.js";
3
4
  import { normalizeSessionSeed } from "./seed.js";
5
+ import { bindOutputContract } from "./output-contract.js";
4
6
  export class LiveSession {
5
7
  id;
6
8
  scheduler;
@@ -24,7 +26,13 @@ export class LiveSession {
24
26
  return this.scheduler.snapshot;
25
27
  }
26
28
  input(event, options) {
27
- return this.scheduler.submit(normalizeInput(event), options);
29
+ const output = options?.outputSchema === undefined ? undefined : bindOutputContract(options.outputSchema);
30
+ if (output && isInteractionReply(event))
31
+ throw new HarnessError("output.invalid-schema", "Interaction replies cannot define a turn outputSchema");
32
+ return this.scheduler.submit(normalizeInput(event), {
33
+ ...(options?.signal === undefined ? {} : { signal: options.signal }),
34
+ ...(output === undefined ? {} : { output }),
35
+ });
28
36
  }
29
37
  interrupt(event, options) {
30
38
  return this.scheduler.submit(normalizeMessage("interrupt", event), options);
@@ -50,6 +58,14 @@ function isInteractionReply(value) {
50
58
  function normalizeMessage(kind, value) {
51
59
  if (typeof value === "string")
52
60
  return { kind, text: value };
61
+ if ("content" in value)
62
+ return {
63
+ kind,
64
+ content: snapshotContent(value.content),
65
+ ...(value.metadata === undefined
66
+ ? {}
67
+ : { metadata: copyJsonObject(value.metadata, "input metadata") }),
68
+ };
53
69
  return {
54
70
  kind,
55
71
  text: value.text,
@@ -65,3 +81,23 @@ function normalizeInput(input) {
65
81
  return input;
66
82
  return normalizeMessage("user-message", input);
67
83
  }
84
+ function snapshotContent(value) {
85
+ if (!Array.isArray(value))
86
+ throw new HarnessError("input.invalid-content", "Input content must be an array");
87
+ return Object.freeze(value.map((part, index) => {
88
+ if (!part || typeof part !== "object")
89
+ throw new HarnessError("input.invalid-content", `Input content part ${index} must be an object`);
90
+ if (part.type === "text") {
91
+ if (typeof part.text !== "string")
92
+ throw new HarnessError("input.invalid-content", `Input text part ${index} must contain text`);
93
+ return Object.freeze({ type: "text", text: part.text });
94
+ }
95
+ if (part.type !== "media" || typeof part.mediaType !== "string" || part.mediaType === "")
96
+ throw new HarnessError("input.invalid-content", `Input media part ${index} must contain a non-empty mediaType`);
97
+ return Object.freeze({
98
+ type: "media",
99
+ mediaType: part.mediaType,
100
+ reference: copyJson(part.reference),
101
+ });
102
+ }));
103
+ }
@@ -12,4 +12,4 @@ export declare function beginTurn(state: SessionSnapshot, turnId: string, event?
12
12
  export declare const commitInput: (state: SessionSnapshot, turnId: string, event: InputEvent) => SessionSnapshot;
13
13
  export declare const commitCandidate: (state: SessionSnapshot, turnId: string, stepId: string, candidate: ModelCandidate) => SessionSnapshot;
14
14
  export declare const commitToolResults: (state: SessionSnapshot, turnId: string, stepId: string, results: readonly ToolResult[]) => SessionSnapshot;
15
- export declare const commitFinal: (state: SessionSnapshot, turnId: string, stepId: string, output: string) => SessionSnapshot;
15
+ export declare const commitFinal: (state: SessionSnapshot, turnId: string, stepId: string, output: import("../types/shared.js").JsonValue) => SessionSnapshot;
@@ -1,15 +1,16 @@
1
1
  import type { InputCompletion, InputHandle, SessionEvent } from "../types/session.js";
2
- export declare class SubmissionStream implements InputHandle {
2
+ import type { JsonValue } from "../types/shared.js";
3
+ export declare class SubmissionStream implements InputHandle<JsonValue> {
3
4
  readonly inputId: string;
4
- readonly completed: Promise<InputCompletion>;
5
+ readonly completed: Promise<InputCompletion<JsonValue>>;
5
6
  private resolveCompletion;
6
7
  private rejectCompletion;
7
8
  private readonly events;
8
9
  private readonly cleanups;
9
10
  private done;
10
11
  constructor(inputId: string);
11
- emit(event: SessionEvent): void;
12
- finish(status: InputCompletion["status"]): void;
12
+ emit(event: SessionEvent<JsonValue>): void;
13
+ finish(status: InputCompletion<JsonValue>["status"]): void;
13
14
  fail(error: unknown): void;
14
15
  onFinish(cleanup: () => void): void;
15
16
  }
@@ -38,6 +38,9 @@ export function canonicalizeOutput(output) {
38
38
  id: call.id,
39
39
  name: call.name,
40
40
  args: call.args,
41
+ ...(block.providerMetadata === undefined
42
+ ? {}
43
+ : { providerMetadata: block.providerMetadata }),
41
44
  ...(block.raw === undefined ? {} : { raw: block.raw }),
42
45
  });
43
46
  })),
@@ -1,5 +1,4 @@
1
1
  import { HarnessError } from "../errors.js";
2
- import { digest } from "../utils/digest.js";
3
2
  import { copyJson } from "../utils/immutable.js";
4
3
  import { SlotDraft } from "./slot-assembly.js";
5
4
  const CONTEXT_TYPE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
@@ -36,7 +35,7 @@ export class ContextDraft {
36
35
  ...(this.hostContext === undefined ? [] : [hostContributor()]),
37
36
  ...slots.map((slot) => contributor(slot.owner, slot.reason)),
38
37
  ]);
39
- return Object.freeze({ items, contributors, digest: digest(items.map(itemDigest)) });
38
+ return Object.freeze({ items, contributors });
40
39
  }
41
40
  }
42
41
  function normalizeItem(item) {
@@ -54,7 +53,6 @@ function hostContributor() {
54
53
  middlewareId: "host",
55
54
  slot: "session",
56
55
  order: 0,
57
- digest: digest({ middlewareId: "host", slot: "session", order: 0 }),
58
56
  });
59
57
  }
60
58
  function contributor(owner, reason) {
@@ -62,10 +60,6 @@ function contributor(owner, reason) {
62
60
  middlewareId: owner.middlewareId,
63
61
  slot: owner.slot,
64
62
  order: owner.order,
65
- digest: digest({ middlewareId: owner.middlewareId, slot: owner.slot, order: owner.order }),
66
63
  ...(reason === undefined ? {} : { reason }),
67
64
  });
68
65
  }
69
- function itemDigest(item) {
70
- return item.type === undefined ? { value: item.value } : { type: item.type, value: item.value };
71
- }
@@ -1,7 +1,6 @@
1
1
  import { bindTool } from "../build/bind-tool.js";
2
2
  import { HarnessError, isHarnessError } from "../errors.js";
3
3
  import { normalizeDirective, sameDirective } from "../model/normalize.js";
4
- import { digest } from "../utils/digest.js";
5
4
  import { copyJson } from "../utils/immutable.js";
6
5
  import { checkedReason, slotOwner, SlotDraft } from "./slot-assembly.js";
7
6
  /** Per-step, middleware-owned model configuration draft. It never persists past the call. */
@@ -80,7 +79,6 @@ export class ModelConfigurationDraft {
80
79
  const toolSlots = this.#tools.values();
81
80
  const instructions = instructionSlots.flatMap((slot) => slot.value.map((text) => Object.freeze({
82
81
  text,
83
- digest: digest(text),
84
82
  contributor: contributor(slot.owner, slot.reason),
85
83
  })));
86
84
  const sourcedTools = toolSlots.flatMap((slot) => slot.value.map((tool) => Object.freeze({ tool, contributor: contributor(slot.owner, slot.reason) })));
@@ -95,15 +93,6 @@ export class ModelConfigurationDraft {
95
93
  throw new HarnessError("configuration.duplicate-tool-name", `Duplicate Tool '${[...duplicate].sort().join("', '")}'`);
96
94
  const toolContracts = sourcedTools.map(({ tool, contributor: source }) => toolContract(tool, source));
97
95
  const model = this.#model?.directive;
98
- const logical = digest({
99
- instructions: instructions.map((item) => item.text),
100
- tools: toolContracts.map(({ name, description, inputSchema }) => ({
101
- name,
102
- ...(description === undefined ? {} : { description }),
103
- inputSchema,
104
- })),
105
- });
106
- const modelDigest = digest(model ?? null);
107
96
  return Object.freeze({
108
97
  version: 1,
109
98
  ...(model === undefined ? {} : { model }),
@@ -115,11 +104,6 @@ export class ModelConfigurationDraft {
115
104
  ...toolSlots.map((slot) => contributor(slot.owner, slot.reason)),
116
105
  ...(this.#model?.owner ? [contributor(this.#model.owner, this.#model.reason)] : []),
117
106
  ]),
118
- digests: Object.freeze({
119
- logical,
120
- model: modelDigest,
121
- request: digest({ logical, model: modelDigest }),
122
- }),
123
107
  });
124
108
  }
125
109
  }
@@ -134,7 +118,6 @@ function contributor(owner, reason) {
134
118
  middlewareId: owner.middlewareId,
135
119
  slot: owner.slot,
136
120
  order: owner.order,
137
- digest: digest({ middlewareId: owner.middlewareId, slot: owner.slot, order: owner.order }),
138
121
  ...(reason === undefined ? {} : { reason }),
139
122
  });
140
123
  }
@@ -142,10 +125,13 @@ function providerTool(tool) {
142
125
  return {
143
126
  name: tool.name,
144
127
  ...(tool.description === undefined ? {} : { description: tool.description }),
145
- inputSchema: copyJson(tool.parameters.jsonSchema),
128
+ inputSchema: copyJson(tool.inputSchema.jsonSchema),
129
+ ...(tool.outputSchema === undefined
130
+ ? {}
131
+ : { outputSchema: copyJson(tool.outputSchema.jsonSchema) }),
146
132
  };
147
133
  }
148
134
  function toolContract(tool, source) {
149
135
  const value = providerTool(tool);
150
- return Object.freeze({ ...value, digest: digest(value), contributor: source });
136
+ return Object.freeze({ ...value, contributor: source });
151
137
  }
@@ -9,9 +9,10 @@ export function projectModelCall(request) {
9
9
  tools: Object.freeze(request.configuration.tools.map((tool) => Object.freeze({
10
10
  name: tool.name,
11
11
  ...(tool.description === undefined ? {} : { description: tool.description }),
12
- inputSchema: copyJson(tool.parameters.jsonSchema),
12
+ inputSchema: copyJson(tool.inputSchema.jsonSchema),
13
13
  }))),
14
14
  ...(request.model === undefined ? {} : { model: copyJson(request.model) }),
15
+ ...(request.outputSchema === undefined ? {} : { outputSchema: copyJson(request.outputSchema) }),
15
16
  sessionId: request.sessionId,
16
17
  });
17
18
  }
@@ -45,12 +46,29 @@ function projectEntry(entry) {
45
46
  if (entry.kind === "input") {
46
47
  if (entry.event.kind !== "user-message" && entry.event.kind !== "interrupt")
47
48
  return [];
48
- return [freezeItem({ kind: "message", role: "user", content: [textPart(entry.event.text)] })];
49
+ return [
50
+ freezeItem({
51
+ kind: "message",
52
+ role: "user",
53
+ content: "content" in entry.event
54
+ ? entry.event.content.map((part) => part.type === "text"
55
+ ? textPart(part.text)
56
+ : Object.freeze({
57
+ type: "media",
58
+ mediaType: part.mediaType,
59
+ reference: copyJson(part.reference),
60
+ }))
61
+ : [textPart(entry.event.text)],
62
+ }),
63
+ ];
49
64
  }
50
65
  if (entry.kind === "candidate") {
51
66
  const content = entry.candidate.output.flatMap((block) => {
52
- if (block.type === "text")
53
- return [textPart(block.text)];
67
+ if (block.type === "text" ||
68
+ (block.type === "reasoning" && block.providerMetadata !== undefined))
69
+ return [Object.freeze({ ...block })];
70
+ if (block.type === "json")
71
+ return [textPart(JSON.stringify(block.value))];
54
72
  if (block.type === "tool-call")
55
73
  return [
56
74
  Object.freeze({
@@ -58,6 +76,9 @@ function projectEntry(entry) {
58
76
  id: block.id,
59
77
  name: block.name,
60
78
  args: copyJson(block.args),
79
+ ...(block.providerMetadata === undefined
80
+ ? {}
81
+ : { providerMetadata: copyJson(block.providerMetadata) }),
61
82
  }),
62
83
  ];
63
84
  return [];
@@ -84,7 +105,12 @@ function toolResultPayload(result) {
84
105
  return result.output;
85
106
  if (result.kind === "denied")
86
107
  return { kind: result.kind, reason: result.reason };
87
- return { kind: result.kind, code: result.code, message: result.message };
108
+ return {
109
+ kind: result.kind,
110
+ code: result.code,
111
+ message: result.message,
112
+ ...(result.details === undefined ? {} : { details: result.details }),
113
+ };
88
114
  }
89
115
  function textPart(text) {
90
116
  return Object.freeze({ type: "text", text });
@@ -6,4 +6,5 @@ export declare function resolveModelRequest(input: {
6
6
  context: StepContext;
7
7
  arrivals: readonly InputEvent[];
8
8
  toolResults: readonly ToolResult[];
9
+ output?: import("../session/output-contract.js").TurnOutputContract;
9
10
  }): ModelRequest;
@@ -12,5 +12,6 @@ export function resolveModelRequest(input) {
12
12
  arrivals: Object.freeze([...input.arrivals]),
13
13
  toolResults: Object.freeze([...input.toolResults]),
14
14
  tools: Object.freeze([...ctx.offeredTools]),
15
+ ...(input.output === undefined ? {} : { outputSchema: input.output.schema.jsonSchema }),
15
16
  });
16
17
  }
@@ -28,5 +28,6 @@ export declare function runStep(input: {
28
28
  readonly context?: import("../types/shared.js").JsonObject;
29
29
  }>;
30
30
  states: CapabilityStateRegistry;
31
+ output?: import("../session/output-contract.js").TurnOutputContract;
31
32
  recordModelRequested(state: SessionSnapshot, active: ActiveModelExecutionRecord): Promise<SessionSnapshot>;
32
33
  }): Promise<StepRunResult>;
package/dist/step/run.js CHANGED
@@ -52,6 +52,7 @@ export async function runStep(input) {
52
52
  context,
53
53
  arrivals: input.arrivals,
54
54
  toolResults: input.toolResults,
55
+ output: input.output,
55
56
  });
56
57
  requestedModelId = request.model?.id;
57
58
  const call = projectModelCall(request);
@@ -76,10 +77,26 @@ export async function runStep(input) {
76
77
  }),
77
78
  }));
78
79
  try {
80
+ let prepared = false;
79
81
  const outcome = await input.agent.invoke(call, {
80
82
  request,
81
83
  invocationId,
82
84
  signal: input.signal,
85
+ reportPreparedCall(value) {
86
+ if (prepared)
87
+ throw new HarnessError("model.adapter-invalid-options", "Model adapter may report only one prepared call per invocation");
88
+ if (typeof value.adapter !== "string" || value.adapter === "")
89
+ throw new HarnessError("model.adapter-invalid-options", "Prepared model call adapter must be a non-empty string");
90
+ prepared = true;
91
+ const reported = Object.freeze({ adapter: value.adapter, call: copyJson(value.call) });
92
+ input.observe(() => ({
93
+ type: "model.prepared",
94
+ turnId: input.turnId,
95
+ stepId: input.stepId,
96
+ ...(request.model?.id === undefined ? {} : { requestedModelId: request.model.id }),
97
+ attributes: reported,
98
+ }));
99
+ },
83
100
  });
84
101
  if (input.signal.aborted)
85
102
  throw input.signal.reason;
@@ -103,7 +120,7 @@ export async function runStep(input) {
103
120
  });
104
121
  }
105
122
  }, input.observe);
106
- const output = sealStep(context);
123
+ const output = sealStep(context, input.output);
107
124
  const candidate = output.kind === "tools" ? output.plan.candidate : context.currentCandidate;
108
125
  return Object.freeze({
109
126
  stepId: input.stepId,
@@ -136,7 +153,6 @@ function snapshotConfiguration(configuration) {
136
153
  tools: snapshotTools(configuration.tools),
137
154
  toolContracts: copyJson(configuration.toolContracts),
138
155
  contributors: copyJson(configuration.contributors),
139
- digests: copyJson(configuration.digests),
140
156
  });
141
157
  }
142
158
  function snapshotTools(tools) {
@@ -144,6 +160,11 @@ function snapshotTools(tools) {
144
160
  name: tool.name,
145
161
  ...(tool.description === undefined ? {} : { description: tool.description }),
146
162
  owner: tool.owner,
147
- parameters: Object.freeze({ jsonSchema: copyJson(tool.parameters.jsonSchema) }),
163
+ inputSchema: Object.freeze({ jsonSchema: copyJson(tool.inputSchema.jsonSchema) }),
164
+ ...(tool.outputSchema === undefined
165
+ ? {}
166
+ : {
167
+ outputSchema: Object.freeze({ jsonSchema: copyJson(tool.outputSchema.jsonSchema) }),
168
+ }),
148
169
  })));
149
170
  }
@@ -2,11 +2,13 @@ import type { ModelCandidate } from "../types/model.js";
2
2
  import type { Tripwire } from "../types/shared.js";
3
3
  import type { BoundToolDefinition, RequiredInteraction, SealedToolCall, ToolOwner, ToolResult } from "../types/tool.js";
4
4
  import type { StepContext } from "./step-context.js";
5
+ import type { TurnOutputContract } from "../session/output-contract.js";
5
6
  export interface ExecutablePlanEntry {
6
7
  readonly call: SealedToolCall;
7
8
  readonly invocationId: string;
8
9
  readonly owner: ToolOwner;
9
10
  readonly execute: BoundToolDefinition["execute"];
11
+ readonly outputSchema?: BoundToolDefinition["outputSchema"];
10
12
  readonly interaction?: RequiredInteraction;
11
13
  }
12
14
  export interface InternalToolPlan {
@@ -23,7 +25,7 @@ export type SealedStepOutput = {
23
25
  readonly tripwire: Tripwire;
24
26
  } | {
25
27
  readonly kind: "final";
26
- readonly output: string;
28
+ readonly output: import("../types/shared.js").JsonValue;
27
29
  } | {
28
30
  readonly kind: "deferred-model";
29
31
  readonly active: import("../types/session.js").ActiveModelExecutionRecord;
@@ -32,4 +34,4 @@ export type SealedStepOutput = {
32
34
  readonly plan: InternalToolPlan;
33
35
  };
34
36
  /** Converts the reviewed canonical candidate into either a final response or an executable tool plan. */
35
- export declare function sealStep(context: StepContext): SealedStepOutput;
37
+ export declare function sealStep(context: StepContext, outputContract?: TurnOutputContract): SealedStepOutput;
package/dist/step/seal.js CHANGED
@@ -2,19 +2,29 @@ import { textFromOutput } from "../model/normalize.js";
2
2
  import { createId } from "../utils/ids.js";
3
3
  import { HarnessError, isHarnessError } from "../errors.js";
4
4
  import { assertJson, copyJson } from "../utils/immutable.js";
5
- const failed = (callId, toolName, code, message) => Object.freeze({ kind: "failed", callId, toolName, code, message });
5
+ const failed = (callId, toolName, code, message, details) => Object.freeze({
6
+ kind: "failed",
7
+ callId,
8
+ toolName,
9
+ code,
10
+ message,
11
+ ...(details ? { details } : {}),
12
+ });
6
13
  /** Converts the reviewed canonical candidate into either a final response or an executable tool plan. */
7
- export function sealStep(context) {
14
+ export function sealStep(context, outputContract) {
8
15
  if (context.currentTripwire)
9
16
  return Object.freeze({ kind: "tripwire", tripwire: context.currentTripwire });
10
17
  if (context.currentModelDeferred)
11
18
  return Object.freeze({ kind: "deferred-model", active: context.currentModelDeferred });
12
19
  const calls = context.canonicalCalls();
13
- if (!calls.length)
20
+ if (!calls.length) {
21
+ if (outputContract)
22
+ return sealStructuredOutput(context, outputContract);
14
23
  return Object.freeze({
15
24
  kind: "final",
16
25
  output: textFromOutput(context.currentCandidate?.output ?? []),
17
26
  });
27
+ }
18
28
  const catalog = context.catalogByName;
19
29
  const sealed = calls.map((call) => sealCall(context, catalog, call));
20
30
  const canonicalCandidate = context.currentCandidate ?? Object.freeze({ output: Object.freeze([]) });
@@ -28,6 +38,28 @@ export function sealStep(context) {
28
38
  }),
29
39
  });
30
40
  }
41
+ function sealStructuredOutput(context, contract) {
42
+ const output = context.currentCandidate?.output ?? [];
43
+ const json = output.filter((block) => block.type === "json");
44
+ if (json.length !== 1 || output.some((block) => block.type === "text"))
45
+ return invalidOutput("Structured terminal output must contain exactly one JSON block and no text blocks");
46
+ const validation = contract.schema.validate(json[0].value);
47
+ if (!validation.ok)
48
+ return invalidOutput(`Structured terminal output failed validation: ${validation.issues.map(renderIssue).join("; ")}`);
49
+ try {
50
+ assertJson(validation.value, "structured terminal output");
51
+ return Object.freeze({ kind: "final", output: copyJson(validation.value) });
52
+ }
53
+ catch (error) {
54
+ return invalidOutput(error instanceof Error ? error.message : String(error));
55
+ }
56
+ }
57
+ function invalidOutput(message) {
58
+ return Object.freeze({
59
+ kind: "tripwire",
60
+ tripwire: Object.freeze({ code: "output.invalid", message }),
61
+ });
62
+ }
31
63
  function sealCall(context, catalog, candidate) {
32
64
  const order = Object.freeze({ callId: candidate.id, toolName: candidate.name });
33
65
  const canonical = Object.freeze({
@@ -55,11 +87,11 @@ function sealCall(context, catalog, candidate) {
55
87
  immediate: failed(candidate.id, candidate.name, "tool.unknown", `Unknown Tool '${candidate.name}'`),
56
88
  };
57
89
  const args = validatedArguments(tool, candidate);
58
- if (isHarnessError(args))
90
+ if (!args.ok)
59
91
  return {
60
92
  order,
61
93
  canonical,
62
- immediate: failed(candidate.id, candidate.name, args.code, args.message),
94
+ immediate: failed(candidate.id, candidate.name, "tool.invalid-arguments", args.message, args.details),
63
95
  };
64
96
  const denial = context.denialFor(candidate.id);
65
97
  if (denial)
@@ -84,26 +116,45 @@ function sealCall(context, catalog, candidate) {
84
116
  call: Object.freeze({
85
117
  callId: candidate.id,
86
118
  toolName: candidate.name,
87
- args,
119
+ args: args.value,
88
120
  }),
89
121
  invocationId: createId("invocation"),
90
122
  owner: tool.owner,
91
123
  execute: tool.execute,
124
+ ...(tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }),
92
125
  ...(interaction ? { interaction } : {}),
93
126
  }),
94
127
  };
95
128
  }
96
129
  function validatedArguments(tool, candidate) {
97
- const validation = tool.parameters.validate(candidate.args);
130
+ const validation = tool.inputSchema.validate(candidate.args);
98
131
  if (!validation.ok)
99
- return new HarnessError("tool.invalid-arguments", validation.issues.join("; "));
132
+ return {
133
+ ok: false,
134
+ message: validation.issues.map(renderIssue).join("; "),
135
+ details: Object.freeze({ phase: "input", issues: validation.issues }),
136
+ };
100
137
  try {
101
138
  assertJson(validation.value, `arguments for '${candidate.name}'`);
102
- return copyJson(validation.value);
139
+ return { ok: true, value: copyJson(validation.value) };
103
140
  }
104
141
  catch (error) {
105
- return isHarnessError(error)
106
- ? error
107
- : new HarnessError("tool.invalid-arguments", String(error), { cause: error });
142
+ return {
143
+ ok: false,
144
+ message: isHarnessError(error) ? error.message : String(error),
145
+ details: Object.freeze({
146
+ phase: "input",
147
+ issues: Object.freeze([
148
+ Object.freeze({
149
+ path: Object.freeze([]),
150
+ code: "invalid_json",
151
+ message: isHarnessError(error) ? error.message : String(error),
152
+ }),
153
+ ]),
154
+ }),
155
+ };
108
156
  }
109
157
  }
158
+ function renderIssue(issue) {
159
+ return `${issue.path.join(".") || "(root)"}: ${issue.message}`;
160
+ }
@@ -1,6 +1,6 @@
1
1
  import { HarnessError, isHarnessError } from "../errors.js";
2
2
  import { createId } from "../utils/ids.js";
3
- import { copyJson, copyJsonObject } from "../utils/immutable.js";
3
+ import { assertJson, copyJson, copyJsonObject } from "../utils/immutable.js";
4
4
  /** Owns one sealed plan's deterministic interaction and concurrent execution progress. */
5
5
  export class ToolPlanRunner {
6
6
  plan;
@@ -309,8 +309,18 @@ function resumeValue(event, pending) {
309
309
  function resultFrom(entry, outcome) {
310
310
  const base = { callId: entry.call.callId, toolName: entry.call.toolName };
311
311
  switch (outcome.kind) {
312
- case "completed":
313
- return Object.freeze({ ...base, kind: "completed", output: copyJson(outcome.output) });
312
+ case "completed": {
313
+ const output = validatedOutput(entry, outcome.output);
314
+ if (!output.ok)
315
+ return Object.freeze({
316
+ ...base,
317
+ kind: "failed",
318
+ code: "tool.invalid-output",
319
+ message: output.message,
320
+ details: output.details,
321
+ });
322
+ return Object.freeze({ ...base, kind: "completed", output: output.value });
323
+ }
314
324
  case "denied":
315
325
  if (typeof outcome.reason !== "string")
316
326
  throw new HarnessError("tool.invalid-tool-result", "Tool denial reason must be a string");
@@ -326,6 +336,31 @@ function resultFrom(entry, outcome) {
326
336
  });
327
337
  }
328
338
  }
339
+ function validatedOutput(entry, value) {
340
+ const validation = entry.outputSchema?.validate(value) ?? { ok: true, value };
341
+ if (!validation.ok)
342
+ return validationFailure("output", validation.issues);
343
+ try {
344
+ assertJson(validation.value, `output for '${entry.call.toolName}'`);
345
+ return { ok: true, value: copyJson(validation.value) };
346
+ }
347
+ catch (error) {
348
+ return validationFailure("output", [
349
+ Object.freeze({
350
+ path: Object.freeze([]),
351
+ code: "invalid_json",
352
+ message: error instanceof Error ? error.message : String(error),
353
+ }),
354
+ ]);
355
+ }
356
+ }
357
+ function validationFailure(phase, issues) {
358
+ return {
359
+ ok: false,
360
+ message: issues.map((item) => `${item.path.join(".") || "(root)"}: ${item.message}`).join("; "),
361
+ details: Object.freeze({ phase, issues: Object.freeze([...issues]) }),
362
+ };
363
+ }
329
364
  function completedEvent(entry, context, result) {
330
365
  return {
331
366
  type: "tool.completed",
@@ -1,22 +1,24 @@
1
1
  import type { ActiveExecutionRecord, InputEvent, SessionEvent, SessionRecord, SessionSnapshot } from "../types/session.js";
2
- import type { JsonObject, Tripwire } from "../types/shared.js";
2
+ import type { JsonObject, JsonValue, Tripwire } from "../types/shared.js";
3
3
  import type { RequiredInteraction } from "../types/tool.js";
4
4
  import type { LoopAgent } from "../build/agent.js";
5
5
  import type { ObserveEmit } from "../utils/observe.js";
6
6
  import { ToolPlanRunner } from "./plan-runner.js";
7
7
  import type { CapabilityStateRegistry } from "../session/capability-state.js";
8
+ import type { TurnOutputContract } from "../session/output-contract.js";
8
9
  export interface PendingTurn {
9
10
  readonly plan: ToolPlanRunner;
10
11
  readonly turnId: string;
11
12
  readonly stepId: string;
12
13
  readonly stepNumber: number;
14
+ readonly output?: TurnOutputContract;
13
15
  }
14
16
  export type TurnProgress = {
15
17
  readonly kind: "final";
16
18
  readonly state: SessionSnapshot;
17
19
  readonly turnId: string;
18
20
  readonly stepId: string;
19
- readonly output: string;
21
+ readonly output: JsonValue;
20
22
  } | {
21
23
  readonly kind: "interaction-required";
22
24
  readonly state: SessionSnapshot;
@@ -42,7 +44,7 @@ export interface TurnRunContext {
42
44
  readonly assertCurrent: () => void;
43
45
  readonly onPlanActive: (pending: PendingTurn | undefined) => void;
44
46
  readonly commit: (state: SessionSnapshot, transition: SessionRecord["transition"], active?: ActiveExecutionRecord) => Promise<SessionSnapshot>;
45
- readonly onConversation: (event: SessionEvent) => void;
47
+ readonly onConversation: (event: SessionEvent<JsonValue>) => void;
46
48
  readonly claimInterrupts: (state: SessionSnapshot, turnId: string) => Promise<{
47
49
  readonly state: SessionSnapshot;
48
50
  readonly arrivals: readonly InputEvent[];
@@ -57,7 +59,7 @@ export declare class TurnRunner {
57
59
  readonly userId?: string;
58
60
  readonly context?: JsonObject;
59
61
  }>);
60
- start(state: SessionSnapshot, event: InputEvent, context: TurnRunContext): Promise<TurnProgress>;
62
+ start(state: SessionSnapshot, event: InputEvent, context: TurnRunContext, output?: TurnOutputContract): Promise<TurnProgress>;
61
63
  continue(state: SessionSnapshot, context: TurnRunContext): Promise<TurnProgress>;
62
64
  resume(state: SessionSnapshot, pending: PendingTurn, event: InputEvent, context: TurnRunContext): Promise<TurnProgress>;
63
65
  private advance;