Selaa lähdekoodia

Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output

Tianyi Cui 2 kuukautta sitten
vanhempi
sitoutus
8f3aca4128
22 muutettua tiedostoa jossa 142 lisäystä ja 18 poistoa
  1. 1 1
      docs/tool-catalog.md
  2. 0 0
      examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md
  3. 0 0
      examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json
  4. 0 0
      examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md
  5. 0 0
      examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json
  6. 0 0
      examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md
  7. 0 0
      examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md
  8. 0 0
      examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json
  9. 0 0
      examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json
  10. 0 0
      examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json
  11. 0 0
      examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json
  12. 0 0
      examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json
  13. 20 3
      packages/cordis/tool-cordis/src/guard.ts
  14. 36 0
      packages/cordis/tool-cordis/tests/mount.spec.ts
  15. 11 5
      packages/core/session/src/json.ts
  16. 13 2
      packages/core/session/tests/json.spec.ts
  17. 1 1
      packages/core/tools/README.md
  18. 24 4
      packages/core/tools/src/json-schema.ts
  19. 6 1
      packages/core/tools/src/schema.ts
  20. 17 0
      packages/core/tools/tests/json-schema.spec.ts
  21. 12 0
      packages/core/tools/tests/schema.spec.ts
  22. 1 1
      packages/workflow/tool-workflow/src/index.ts

+ 1 - 1
docs/tool-catalog.md

@@ -703,7 +703,7 @@ Run a JavaScript workflow script that orchestrates subagents at scale. Use this
 The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.
 
 Script-body hooks:
-- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.
+- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.
 - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.
 - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.
 - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json


+ 20 - 3
packages/cordis/tool-cordis/src/guard.ts

@@ -29,7 +29,9 @@ type DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true }
 type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
 
 function isPlainRecord(value: unknown): value is Record<string, unknown> {
-  return Object.prototype.toString.call(value) === '[object Object]'
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
+  const prototype: unknown = Object.getPrototypeOf(value)
+  return prototype === null || Object.getPrototypeOf(prototype) === null
 }
 
 /** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */
@@ -44,6 +46,9 @@ function cloneJson(value: unknown, path: string, seen = new Set<object>()): unkn
   seen.add(value)
   try {
     if (Array.isArray(value)) {
+      if (Reflect.ownKeys(value).length !== value.length + 1) {
+        throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
+      }
       const output: unknown[] = []
       for (let index = 0; index < value.length; index++) {
         if (!Object.hasOwn(value, index)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
@@ -53,7 +58,14 @@ function cloneJson(value: unknown, path: string, seen = new Set<object>()): unkn
     }
     if (!isPlainRecord(value)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
     const output: Record<string, unknown> = {}
-    for (const [key, entry] of Object.entries(value)) output[key] = cloneJson(entry, `${path}.${key}`, seen)
+    for (const [key, entry] of Object.entries(value)) {
+      Object.defineProperty(output, key, {
+        value: cloneJson(entry, `${path}.${key}`, seen),
+        enumerable: true,
+        configurable: true,
+        writable: true,
+      })
+    }
     return output
   } finally {
     seen.delete(value)
@@ -131,7 +143,12 @@ function normalizePropertyMap(
 ): Record<string, unknown> {
   const spec: Record<string, unknown> = {}
   for (const [key, prop] of Object.entries(entries)) {
-    spec[key] = normalizeValueSchema(prop, `${path}.${key}`, requiredNames.has(key), raw, true)
+    Object.defineProperty(spec, key, {
+      value: normalizeValueSchema(prop, `${path}.${key}`, requiredNames.has(key), raw, true),
+      enumerable: true,
+      configurable: true,
+      writable: true,
+    })
   }
   return spec
 }

+ 36 - 0
packages/cordis/tool-cordis/tests/mount.spec.ts

@@ -391,6 +391,8 @@ describe('cordis_mount', () => {
     ['parameters: { value: { type: \'json\', default: () => 1 } }', 'parameters.value.default must be lossless JSON data'],
     ['parameters: { value: { type: \'json\', default: (() => { const v = {}; v.self = v; return v })() } }', 'parameters.value.default.self must be lossless JSON data'],
     ['parameters: { value: { type: \'json\', default: Array(2) } }', 'parameters.value.default must be lossless JSON data'],
+    ['parameters: { value: { type: \'json\', default: Object.assign([1], { extra: true }) } }', 'parameters.value.default must be lossless JSON data'],
+    ['parameters: { value: { type: \'json\', default: new (class DefaultValue { constructor() { this.ok = true } })() } }', 'parameters.value.default must be lossless JSON data'],
     ['parameters: { value: { type: \'json\', default: new Date(0) } }', 'parameters.value.default must be lossless JSON data'],
   ])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => {
     const ctx = await setup()
@@ -415,6 +417,40 @@ describe('cordis_mount', () => {
     expect(text(result)).toContain(message)
   })
 
+  it('preserves literal __proto__ keys in sandbox schemas and annotations', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'proto-schema',
+          inject: ['tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'proto_schema_tool',
+              description: 'literal JSON keys',
+              parameters: {
+                ['__proto__']: { type: 'string', required: true },
+                value: { type: 'json', default: { ['__proto__']: { safe: true } } },
+              },
+              async execute() { return [] },
+            }))
+          },
+        }
+      `,
+    })
+
+    expect(result.isError).toBe(false)
+    const parameters = ctx.tools.schemas().find(schema => schema.name === 'proto_schema_tool')!.parameters as {
+      properties: Record<string, { default?: unknown }>
+      required?: string[]
+    }
+    expect(Object.hasOwn(parameters.properties, '__proto__')).toBe(true)
+    expect(parameters.required).toContain('__proto__')
+    const defaultValue = parameters.properties.value!.default as Record<string, unknown>
+    expect(Object.hasOwn(defaultValue, '__proto__')).toBe(true)
+    expect(defaultValue.__proto__).toEqual({ safe: true })
+  })
+
   it('accepts a nested object/array ParameterSchemaSpec (the DSL recursion)', async () => {
     const ctx = await setup()
     const result = await call(ctx, 'cordis_mount', {

+ 11 - 5
packages/core/session/src/json.ts

@@ -3,11 +3,12 @@
 /**
  * A value that round-trips losslessly through JSON: `null`, a boolean, a finite
  * number other than negative zero, a string, an array of such values, or a
- * plain object whose values are such values. TypeScript cannot distinguish
- * `-0` from `number`, so {@link isJsonValue} and {@link snapshotJsonValue}
- * enforce that last numeric detail at runtime. Use this type for a payload that
- * must survive session-log persistence and replay byte-identically — e.g. a
- * tool's private presentation `meta`.
+ * plain object whose values are such values. Arrays may carry only their dense
+ * indexed elements; extra own properties would be discarded by JSON. TypeScript
+ * cannot distinguish `-0` from `number`, so {@link isJsonValue} and
+ * {@link snapshotJsonValue} enforce these details at runtime. Use this type for
+ * a payload that must survive session-log persistence and replay byte-identically
+ * — e.g. a tool's private presentation `meta`.
  */
 export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
 
@@ -47,6 +48,10 @@ export function snapshotJsonValue<T>(value: T): T | undefined {
       if (Array.isArray(current)) {
         if (Object.getPrototypeOf(current) !== Array.prototype) return undefined
         const length = current.length
+        // Every ordinary array owns `length`; dense indexed elements account
+        // for the remaining keys. Anything else would be lost by JSON and by
+        // structured clone, including symbols and non-enumerable properties.
+        if (Reflect.ownKeys(current).length !== length + 1) return undefined
         const snapshot: JsonValue[] = []
         for (let index = 0; index < length; index++) {
           if (!Object.prototype.hasOwnProperty.call(current, index)) return undefined
@@ -111,6 +116,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
   try {
     if (Array.isArray(value)) {
       if (Object.getPrototypeOf(value) !== Array.prototype) return false
+      if (Reflect.ownKeys(value).length !== value.length + 1) return false
       // Reject sparse arrays: a hole is skipped by `every`/`forEach` but
       // JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
       // lossily. Require every index 0..length-1 to be an OWN property.

+ 13 - 2
packages/core/session/tests/json.spec.ts

@@ -63,12 +63,16 @@ describe('snapshotJsonValue', () => {
     expect(arrayReads).toBe(1)
   })
 
-  it('rejects exotic containers, sparse arrays, cycles, and invalid children', () => {
+  it('rejects exotic containers, sparse or decorated arrays, cycles, and invalid children', () => {
     class ExoticObject {
       readonly value = 1
     }
     class ExoticArray extends Array<number> {}
     const sparse = new Array<number>(1)
+    const decorated = [1]
+    Object.defineProperty(decorated, 'extra', { value: true })
+    const symbolDecorated = [1]
+    Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true })
     const cyclic: Record<string, unknown> = {}
     cyclic.self = cyclic
 
@@ -76,6 +80,8 @@ describe('snapshotJsonValue', () => {
     expect(snapshotJsonValue(new Map([['value', 1]]))).toBeUndefined()
     expect(snapshotJsonValue(new ExoticArray(1))).toBeUndefined()
     expect(snapshotJsonValue(sparse)).toBeUndefined()
+    expect(snapshotJsonValue(decorated)).toBeUndefined()
+    expect(snapshotJsonValue(symbolDecorated)).toBeUndefined()
     expect(snapshotJsonValue(cyclic)).toBeUndefined()
     expect(snapshotJsonValue([undefined])).toBeUndefined()
     expect(snapshotJsonValue({ value: undefined })).toBeUndefined()
@@ -133,16 +139,21 @@ describe('isJsonValue', () => {
     expect(isJsonValue(nullPrototype)).toBe(true)
   })
 
-  it('rejects sparse arrays, invalid children, exotic objects, and cycles', () => {
+  it('rejects sparse or decorated arrays, invalid children, exotic objects, and cycles', () => {
     class Exotic {
       readonly value = 1
     }
     class ExoticArray extends Array<number> {}
     const sparse = new Array<number>(1)
+    const decorated = Object.assign([1], { extra: true })
+    const symbolDecorated = [1]
+    Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true })
     const cyclic: Record<string, unknown> = {}
     cyclic.self = cyclic
 
     expect(isJsonValue(sparse)).toBe(false)
+    expect(isJsonValue(decorated)).toBe(false)
+    expect(isJsonValue(symbolDecorated)).toBe(false)
     expect(isJsonValue(new ExoticArray(1))).toBe(false)
     expect(isJsonValue([undefined])).toBe(false)
     expect(isJsonValue({ value: undefined })).toBe(false)

+ 1 - 1
packages/core/tools/README.md

@@ -181,7 +181,7 @@ Append-only; newly visible content follows the reusable request prefix and does
 
 - **Concurrency policy is not an event seam** — `executionMode()` reads the resolved tool definition directly; plugins can only declare a classifier on definitions they own.
 - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
-- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and preserves `default` as a model-visible JSON Schema annotation without applying it during validation; dynamic Cordis mounts may supply defaults even though first-party definitions do not, while raw-registered JSON-Schema tools validate their own input.
+- **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary supports every JSON root.
 - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
 - **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only.
 - **Code Mode bindings return text only** — non-text content blocks in a sub-call result collapse to `[<type> content]` placeholders.

+ 24 - 4
packages/core/tools/src/json-schema.ts

@@ -235,13 +235,21 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
       case 'boolean':
       case 'null': {
         const allowed = node.enum
+        const enumValid = Array.isArray(allowed)
+          && allowed.length > 0
+          && allowed.every(entry => scalarMatches(schemaType, entry))
         if (Object.hasOwn(node, 'enum')) {
-          if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => scalarMatches(schemaType, entry))) {
+          if (!enumValid) {
             violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`)
           }
         }
-        if (Object.hasOwn(node, 'const') && !scalarMatches(schemaType, node.const)) {
-          violations.push(`${path}.const must be a ${schemaType} value`)
+        const constValid = scalarMatches(schemaType, node.const)
+        if (Object.hasOwn(node, 'const')) {
+          if (!constValid) {
+            violations.push(`${path}.const must be a ${schemaType} value`)
+          } else if (enumValid && !allowed.includes(node.const as JsonSchemaScalar)) {
+            violations.push(`${path}.const must be one of ${path}.enum when both are declared`)
+          }
         }
         break
       }
@@ -300,8 +308,20 @@ function propertyPath(path: string, key: string): string {
   return path === '' ? key : `${path}.${key}`
 }
 
-/** Collect value violations for one trusted schema node. */
+/** Contain hostile getters/proxies so validation remains total for arbitrary values. */
 function checkValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
+  if (node.type !== undefined && !(SCHEMA_TYPES as readonly unknown[]).includes(node.type)) {
+    return checkValueUnchecked(node, value, path)
+  }
+  try {
+    return checkValueUnchecked(node, value, path)
+  } catch {
+    return [`"${diagnosticPath(path)}" must be a lossless JSON value`]
+  }
+}
+
+/** Collect value violations for one trusted schema node after the exception boundary. */
+function checkValueUnchecked(node: JsonSchemaNode, value: unknown, path: string): string[] {
   if (node.oneOf !== undefined) {
     const matches = node.oneOf.filter(branch => checkValue(branch, value, path).length === 0).length
     return matches === 1 ? [] : [`"${diagnosticPath(path)}" must match exactly one oneOf branch (matched ${matches})`]

+ 6 - 1
packages/core/tools/src/schema.ts

@@ -203,7 +203,12 @@ function compilePropertyMap(
       if (Object.hasOwn(property, 'required') && property.required !== true) {
         authorError(`${path}.${key}.required must be true when present`)
       }
-      properties[key] = compileValueSchema(property, `${path}.${key}`, seen, true)
+      Object.defineProperty(properties, key, {
+        value: compileValueSchema(property, `${path}.${key}`, seen, true),
+        enumerable: true,
+        configurable: true,
+        writable: true,
+      })
       if (property.required === true) required.push(key)
     }
     return required.length > 0 ? { properties, required } : { properties }

+ 17 - 0
packages/core/tools/tests/json-schema.spec.ts

@@ -160,6 +160,8 @@ describe('the enforced raw JSON Schema subset', () => {
       .toEqual(['schema.const must be a boolean value'])
     expect(violationsOf({ type: 'string', enum: undefined }))
       .toEqual(['schema.enum must be a non-empty array of string values'])
+    expect(violationsOf({ type: 'string', enum: ['a'], const: 'b' }))
+      .toEqual(['schema.const must be one of schema.enum when both are declared'])
   })
 
   it('validates annotation types and lossless JSON payloads', () => {
@@ -267,6 +269,21 @@ describe('validateJsonSchemaValue', () => {
       .toEqual(['"value" must be an object'])
   })
 
+  it('returns a violation instead of throwing for a container with a hostile getter', () => {
+    const value = Object.defineProperty({}, 'answer', {
+      enumerable: true,
+      get() { throw new Error('getter exploded') },
+    })
+    const schema = asserted({
+      type: 'object',
+      properties: { answer: { type: 'integer' } },
+      required: ['answer'],
+    })
+
+    expect(validateJsonSchemaValue(schema, value))
+      .toEqual(['"value" must be a lossless JSON value'])
+  })
+
   it('validates dense arrays per index and rejects lossy arrays', () => {
     const schema = asserted({ type: 'array', items: { type: 'integer' } })
     expect(validateJsonSchemaValue(schema, [1, 2])).toEqual([])

+ 12 - 0
packages/core/tools/tests/schema.spec.ts

@@ -62,6 +62,7 @@ describe('the unified author schema DSL', () => {
       { type: 'object' },
       { oneOf: [{ type: 'string' }] },
       { type: 'number', enum: ['1'] },
+      { type: 'string', enum: ['a'], const: 'b' },
       { type: 'integer', const: 1.5 },
       { type: 'json', default: undefined },
       { type: 'array', items: { type: 'string', required: true } },
@@ -91,6 +92,17 @@ describe('the unified author schema DSL', () => {
     expect(() => parameterSchemaSpecToJsonSchema(properties as ParameterSchemaSpec)).toThrow(/circular/)
   })
 
+  it('preserves a property literally named __proto__ as schema data', () => {
+    const properties = Object.create(null) as ParameterSchemaSpec
+    properties.__proto__ = { type: 'string', required: true }
+
+    const schema = parameterSchemaSpecToJsonSchema(properties)
+
+    expect(Object.hasOwn(schema.properties, '__proto__')).toBe(true)
+    expect(schema.properties.__proto__).toEqual({ type: 'string' })
+    expect(schema.required).toEqual(['__proto__'])
+  })
+
   it('infers scalar literals, arrays, objects, json, and exact-one unions', () => {
     expectTypeOf<InferValue<{ type: 'string'; enum: readonly ['a', 'b'] }>>().toEqualTypeOf<'a' | 'b'>()
     expectTypeOf<InferValue<{ type: 'number'; const: 1 }>>().toEqualTypeOf<1>()

+ 1 - 1
packages/workflow/tool-workflow/src/index.ts

@@ -48,7 +48,7 @@ const DESCRIPTION = `Run a JavaScript workflow script that orchestrates subagent
 The workflow's identity rides the \`meta\` parameter as JSON: required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, provider?, model?}\`). The \`script\` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO \`export const meta\` statement — meta is a parameter, not code), running with top-level await; end with \`return <value>\` — the value must be JSON-serializable and is this tool's result.
 
 Script-body hooks:
-- \`agent(prompt, opts?): Promise<any>\` — run one subagent to completion. Without \`opts.schema\` it resolves to the child's final text; with \`opts.schema\` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves \`null\` when the child fails (filter with \`.filter(Boolean)\`). Other opts: \`label\` (display), \`phase\` (progress group), and independent \`provider\`/\`model\` LLM target overrides (either may be provided alone). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly.
+- \`agent(prompt, opts?): Promise<any>\` — run one subagent to completion. Without \`opts.schema\` it resolves to the child's final text; with \`opts.schema\` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves \`null\` when the child fails (filter with \`.filter(Boolean)\`). Other opts: \`label\` (display), \`phase\` (progress group), and independent \`provider\`/\`model\` LLM target overrides (either may be provided alone). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly.
 - \`pipeline(items, ...stages): Promise<any[]>\` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives \`(prev, item, index)\`. An ordinary stage throw drops that ITEM to \`null\` and skips its remaining stages.
 - \`parallel(thunks): Promise<any[]>\` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to \`null\`.
 - \`phase(title)\` — start a progress phase; \`log(message)\` — narrate progress; \`args\` — the tool call's \`args\` input, verbatim.

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä