From 4dcab9735826dd5461b3a6be949f3a0ce871855d Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Thu, 16 Jul 2026 17:55:04 -0300 Subject: [PATCH 1/2] =?UTF-8?q?(MOT-3948)=20feat(fp):=20standalone=20fp=20?= =?UTF-8?q?worker=20=E2=80=94=20pipe=20+=2017=20lodash-style=20transforms?= =?UTF-8?q?=20+=20injected=20guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fp::pipe runs worker-side pipelines in one call: fp::* transform steps execute inline, every other step goes over the bus; the caller gets per-step size receipts and a preview (default 400 chars, capped at 8000), never the value - steps run with worker authority, so the pipe statically refuses every agent-policy hard-denied class (credentials, model spend, turn control, session/approval, bus internals) — state::* stays allowed on purpose: the approval-gated pipe call is the surface an approver reviews - seventeen fp::* transforms with lodash semantics and teachable errors: get/pick/omit/take/drop/map/filter/split/join/uniq plus size/compact/nth/getOr/flatten/sortBy/reverse (fp calling convention: capped arity, config in payload, data threaded last). Deliberate loud deviations where lodash is silent: misses name available keys, compact removes null only, nth OOB and size on non-collections error, sortBy refuses holes and mixed key types; refusal errors redirect to the sanctioned pattern (nest refusal teaches the seed rule; sortBy "." miss teaches that the pointer for the element itself is "") - usage guidance is injected into the agent system prompt via a harness::hook::pre-generate hook (fail_open, presence-gated, one-shot bind relying on engine recoverable triggers); transforms are allowed for agents in the active root policy, fp::pipe stays needs_approval - console: fp tool family (pipe/transform views, parsers, param chips, fixture gallery covering all seventeen ops and the teachable error cards) - harness: pipe/util moved out; react/session_id prompt fixes stay, with the stale "leave metadata.session_id out" sentence reconciled - release wiring: fp in create-tag options and release tag triggers; tests/ contract suite for the changed-worker CI gate --- .github/workflows/create-tag.yml | 1 + .github/workflows/release.yml | 1 + README.md | 1 + .../chat/FunctionCallMessage.stories.tsx | 6 + .../web/src/components/chat/fp/PipeView.tsx | 125 + .../web/src/components/chat/fp/UtilView.tsx | 81 + .../chat/fp/__tests__/parsers.test.ts | 142 ++ console/web/src/components/chat/fp/index.tsx | 96 + console/web/src/components/chat/fp/parsers.ts | 114 + .../function-call/FunctionCallCard.tsx | 6 + .../web/src/stories/fixtures/fp-fixtures.ts | 283 +++ fp/Cargo.lock | 2182 +++++++++++++++++ fp/Cargo.toml | 29 + fp/README.md | 107 + fp/iii-permissions.yaml | 34 + fp/iii.worker.yaml | 7 + fp/skills/SKILL.md | 70 + fp/src/guidance.rs | 255 ++ fp/src/lib.rs | 8 + fp/src/main.rs | 189 ++ fp/src/pipe.rs | 537 ++++ fp/src/util.rs | 901 +++++++ fp/tests/contract.rs | 85 + harness/prompts/cli.txt | 25 +- harness/prompts/default.txt | 37 +- iii-permissions.yaml | 22 + provider-anthropic/prompts/identity.txt | 3 +- provider-llamacpp/prompts/identity.txt | 3 +- provider-openai-codex/prompts/identity.txt | 11 +- provider-openai/prompts/identity.txt | 11 +- provider-xai/prompts/identity.txt | 11 +- provider-zai/prompts/identity.txt | 3 +- 32 files changed, 5360 insertions(+), 26 deletions(-) create mode 100644 console/web/src/components/chat/fp/PipeView.tsx create mode 100644 console/web/src/components/chat/fp/UtilView.tsx create mode 100644 console/web/src/components/chat/fp/__tests__/parsers.test.ts create mode 100644 console/web/src/components/chat/fp/index.tsx create mode 100644 console/web/src/components/chat/fp/parsers.ts create mode 100644 console/web/src/stories/fixtures/fp-fixtures.ts create mode 100644 fp/Cargo.lock create mode 100644 fp/Cargo.toml create mode 100644 fp/README.md create mode 100644 fp/iii-permissions.yaml create mode 100644 fp/iii.worker.yaml create mode 100644 fp/skills/SKILL.md create mode 100644 fp/src/guidance.rs create mode 100644 fp/src/lib.rs create mode 100644 fp/src/main.rs create mode 100644 fp/src/pipe.rs create mode 100644 fp/src/util.rs create mode 100644 fp/tests/contract.rs diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index 127358f56..71245b64d 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -30,6 +30,7 @@ on: - lsp-vscode - image-resize - llm-router + - fp - mcp - memory - memory-consolidate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cff32a3f4..f8446088b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,7 @@ on: - 'lsp/v*' - 'image-resize/v*' - 'llm-router/v*' + - 'fp/v*' - 'mcp/v*' - 'memory/v*' - 'memory-consolidate/v*' diff --git a/README.md b/README.md index 57c27e09f..a521b024b 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ npx skills add iii-hq/iii --all | [`lsp`](lsp/) | Rust | Language Server for iii function ids, trigger configs, and worker discovery. Autocomplete / hover across JS/TS, Python, Rust. | | [`lsp-vscode`](lsp-vscode/) | Node | VS Code extension package `iii-lsp`, embedding the `lsp` server. | | [`image-resize`](image-resize/) | Rust | Image resize via channel I/O — JPEG/PNG/WebP with EXIF auto-orient, scale-to-fit / crop-to-fit. | +| [`fp`](fp/) | Rust | Lodash-style value transforms (`fp::get`/`pick`/`take`/…) and `fp::pipe` — worker-side pipelines that move big values function→function without routing them through the model. Injects its usage guidance via the harness `pre-generate` hook. | | [`llm-router`](llm-router/) | Rust | One front door + provider protocol in front of every LLM provider — `router::chat`/`router::complete`/`router::embed`, provider registry + credentials, model catalog, and routing. See [`llm-router/README.md`](llm-router/README.md). | | [`mcp`](mcp/) | Rust | MCP 2025-06-18 Streamable HTTP bridge — exposes iii functions tagged `mcp.expose` as MCP tools. | | [`memory`](memory/) | Rust | Durable cross-session agent memory — named banks of always-injected markdown rules and auto-extracted memories, hybrid BM25 + entity + semantic recall, pinning, supersede-never-delete history, and two live trigger types. Plain files on disk; binds the harness `pre-generate` hook for injection and `turn-completed` for background capture. | diff --git a/console/web/src/components/chat/FunctionCallMessage.stories.tsx b/console/web/src/components/chat/FunctionCallMessage.stories.tsx index 555f2d481..57a195731 100644 --- a/console/web/src/components/chat/FunctionCallMessage.stories.tsx +++ b/console/web/src/components/chat/FunctionCallMessage.stories.tsx @@ -4,6 +4,7 @@ import { coderFixtures } from '@/stories/fixtures/coder-fixtures' import { directoryFixtures } from '@/stories/fixtures/directory-fixtures' import { engineFixtures } from '@/stories/fixtures/engine-fixtures' import { harnessFixtures } from '@/stories/fixtures/harness-fixtures' +import { fpFixtures } from '@/stories/fixtures/fp-fixtures' import { routerFixtures } from '@/stories/fixtures/router-fixtures' import { sandboxFixtures } from '@/stories/fixtures/sandbox-fixtures' import { scraplingFixtures } from '@/stories/fixtures/scrapling-fixtures' @@ -207,6 +208,11 @@ export const HarnessFamily: Story = { render: () => , } +export const FpFamily: Story = { + name: 'fp family', + render: () => , +} + export const StateFamily: Story = { name: 'state family', render: () => , diff --git a/console/web/src/components/chat/fp/PipeView.tsx b/console/web/src/components/chat/fp/PipeView.tsx new file mode 100644 index 000000000..5896a5065 --- /dev/null +++ b/console/web/src/components/chat/fp/PipeView.tsx @@ -0,0 +1,125 @@ +import { FilterChip } from '@/components/chat/engine/shared' +import { MetaRow, StatusPill } from '@/components/chat/sandbox/shared' +import { + type PipeResponse, + pipeRequestSchema, + pipeResponseSchema, + safeParseRequest, +} from './parsers' + +interface PipeViewProps { + input: unknown + /** Already unwrapped once by the dispatcher — never unwrap again. */ + output?: unknown + running?: boolean +} + +/** Single-line payload summary under each step; empty payloads render + nothing (the raw json tab keeps the full call). */ +function compactPayload(payload: unknown): string | null { + if (payload == null) return null + if ( + typeof payload === 'object' && + !Array.isArray(payload) && + Object.keys(payload as Record).length === 0 + ) { + return null + } + return JSON.stringify(payload) +} + +/** Dim any `ns::` prefix so the op tail reads clearly (`state::set`, + `fp::get`), mirroring the header id labels. */ +function StepFunctionLabel({ functionId }: { functionId: string }) { + const splitAt = functionId.lastIndexOf('::') + if (splitAt <= 0) return {functionId} + return ( + <> + {functionId.slice(0, splitAt + 2)} + {functionId.slice(splitAt + 2)} + + ) +} + +/** `fp::pipe` — the step route with per-step receipt sizes, then the + final value preview. The threaded values themselves never reach the chat + (that is the point of the pipe), so there is no value pane. */ +export function PipeView({ input, output, running }: PipeViewProps) { + const req = safeParseRequest(pipeRequestSchema, input) + if (!req?.through?.length) return null + + const parsed = running ? null : pipeResponseSchema.safeParse(output) + const res: PipeResponse | null = parsed?.success ? parsed.data : null + const receipts = res?.steps ?? null + const threaded = receipts?.length + ? receipts[receipts.length - 1]?.chars + : null + + return ( +
+ + {/* only claim ok once receipts exist — approval previews render + this view before the pipe has run */} + + + {threaded != null ? ( + + ) : null} + +
    + {req.through.map((step, i) => { + const receipt = receipts?.[i] + const payload = compactPayload(step.payload) + return ( +
  • +
    + + {i + 1} + + + + + {step.into ? ( + + ) : null} + {receipt?.chars != null ? ( + + → {receipt.chars.toLocaleString()} chars + + ) : null} +
    + {payload ? ( +
    + {payload} +
    + ) : null} +
  • + ) + })} +
+ {running ? ( +
+ · piping… +
+ ) : res?.value_preview ? ( +
+
+ preview +
+
+            {res.value_preview}
+          
+
+ ) : null} +
+ ) +} diff --git a/console/web/src/components/chat/fp/UtilView.tsx b/console/web/src/components/chat/fp/UtilView.tsx new file mode 100644 index 000000000..7c1841790 --- /dev/null +++ b/console/web/src/components/chat/fp/UtilView.tsx @@ -0,0 +1,81 @@ +import { FilterChip } from '@/components/chat/engine/shared' +import { MetaRow, StatusPill } from '@/components/chat/sandbox/shared' +import { JsonHighlight } from '@/lib/syntax' +import { + safeParseRequest, + transformOp, + unwrapEnvelope, + utilRequestSchema, + utilResponseSchema, +} from './parsers' + +interface UtilViewProps { + functionId: string + input: unknown + output: unknown + running?: boolean +} + +/** `null`, `undefined`, `""`, `[]`, `{}` render as a compact "empty" note + * rather than a noisy JSON block. Mirrors `StateView`. */ +function isEmptyValue(v: unknown): boolean { + if (v === null || v === undefined) return true + if (typeof v === 'string') return v.length === 0 + if (Array.isArray(v)) return v.length === 0 + if (typeof v === 'object') { + return Object.keys(v as Record).length === 0 + } + return false +} + +/** `fp::*` — the seventeen lodash-style transforms (`FP_TRANSFORM_OPS`). + Op label + its params as chips, then the transformed value. */ +export function UtilView({ functionId, input, output, running }: UtilViewProps) { + const req = safeParseRequest(utilRequestSchema, input) + const op = transformOp(functionId) + + // Success details is the `UtilResponse { value }` wrapper — show the value. + const unwrapped = running ? undefined : unwrapEnvelope(output) + const parsed = utilResponseSchema.safeParse(unwrapped) + const value = parsed.success ? parsed.data.value : unwrapped + const empty = !running && isEmptyValue(value) + + return ( +
+ + + {req?.path != null ? ( + + ) : null} + {req?.paths?.length ? ( + + ) : null} + {req?.n != null ? : null} + {req?.separator != null ? ( + + ) : null} + {req?.matches ? ( + + ) : null} + {req !== null && req.default !== undefined ? ( + + ) : null} + + {running ? ( +
+ · running… +
+ ) : empty ? ( +
+ · empty +
+ ) : typeof value === 'string' ? ( +
+          {value}
+        
+ ) : ( + + )} +
+ ) +} diff --git a/console/web/src/components/chat/fp/__tests__/parsers.test.ts b/console/web/src/components/chat/fp/__tests__/parsers.test.ts new file mode 100644 index 000000000..2afc6e049 --- /dev/null +++ b/console/web/src/components/chat/fp/__tests__/parsers.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers' +import { + isFpFunction, + isFpTransformFunction, + FP_TRANSFORM_OPS, + pipeRequestSchema, + pipeResponseSchema, + safeParseRequest, + transformOp, + unwrapEnvelope, + utilRequestSchema, + utilResponseSchema, +} from '../parsers' + +/** entry-mapper success shape: { content, details }. */ +function resultEnvelope(text: string, details: unknown) { + return { content: [{ type: 'text', text }], details } +} + +/** entry-mapper error shape (functionResultOutput, is_error branch). */ +function errorEnvelope(code: string, message: string) { + return { + error: { + kind: 'function_error', + message, + details: { error: code, message }, + content: [{ type: 'text', text: message }], + }, + } +} + +describe('isFpFunction', () => { + it('matches the pipe and every transform id', () => { + expect(isFpFunction('fp::pipe')).toBe(true) + for (const op of FP_TRANSFORM_OPS) { + expect(isFpFunction(`fp::${op}`)).toBe(true) + expect(isFpTransformFunction(`fp::${op}`)).toBe(true) + expect(transformOp(`fp::${op}`)).toBe(op) + } + }) + + it('rejects the internal hook function and unrelated ids', () => { + // explicit id set, NOT a prefix match — the worker's internal plumbing + // must never fall into the transform view + expect(isFpFunction('fp::inject-guidance')).toBe(false) + expect(isFpTransformFunction('fp::pipe')).toBe(false) + expect(isFpTransformFunction('fp::getter')).toBe(false) + expect(isFpFunction('harness::pipe')).toBe(false) + expect(isFpFunction('fp::')).toBe(false) + }) +}) + +describe('pipe schemas', () => { + it('parses the canonical through request', () => { + const r = safeParseRequest(pipeRequestSchema, { + through: [ + { function: 'scrapling::fetch', payload: { url: 'u' } }, + { function: 'fp::get', payload: { path: '/content' } }, + { function: 'state::set', payload: { scope: 's' }, into: '/value' }, + ], + preview_chars: 200, + }) + expect(r?.through?.length).toBe(3) + expect(r?.through?.[2]?.into).toBe('/value') + }) + + it('tolerates a clipped approval excerpt', () => { + expect(safeParseRequest(pipeRequestSchema, {})).not.toBeNull() + }) + + it('parses receipts + preview out of success details', () => { + const details = { + steps: [ + { function: 'scrapling::fetch', chars: 84213 }, + { function: 'state::set', chars: 46 }, + ], + value_preview: '## Circuit breakers…', + } + const parsed = pipeResponseSchema.safeParse( + unwrapEnvelope(resultEnvelope(JSON.stringify(details), details)), + ) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.steps?.[0]?.chars).toBe(84213) + expect(parsed.data.value_preview).toContain('Circuit') + } + }) + + it('routes a step failure to the invocation error display', () => { + const display = parseSandboxErrorDisplay( + errorEnvelope( + 'handler_error', + 'pipe failed at step 2 (fp::get): path "/body" matched nothing · completed: scrapling::fetch→84213ch', + ), + ) + expect(display?.variant).toBe('invocation') + }) +}) + +describe('util schemas', () => { + it('reads per-op params from one loose request schema', () => { + expect( + safeParseRequest(utilRequestSchema, { value: {}, path: '/a' })?.path, + ).toBe('/a') + expect( + safeParseRequest(utilRequestSchema, { value: {}, paths: ['a', 'b'] }) + ?.paths, + ).toEqual(['a', 'b']) + expect(safeParseRequest(utilRequestSchema, { value: 's', n: 20 })?.n).toBe( + 20, + ) + expect( + safeParseRequest(utilRequestSchema, { + value: [], + matches: { status: 'active' }, + })?.matches, + ).toEqual({ status: 'active' }) + // fp::nth negative index and fp::getOr's default both parse + expect(safeParseRequest(utilRequestSchema, { value: [], n: -1 })?.n).toBe( + -1, + ) + expect( + safeParseRequest(utilRequestSchema, { + value: {}, + path: '/etag', + default: 'no-etag', + })?.default, + ).toBe('no-etag') + }) + + it('unwraps the UtilResponse value wrapper', () => { + const unwrapped = unwrapEnvelope(resultEnvelope('hé', { value: 'hé' })) + const parsed = utilResponseSchema.safeParse(unwrapped) + expect(parsed.success).toBe(true) + if (parsed.success) expect(parsed.data.value).toBe('hé') + }) + + it('rejects a non-wrapper value so views fall back to the raw output', () => { + expect(utilResponseSchema.safeParse('bare string').success).toBe(false) + }) +}) diff --git a/console/web/src/components/chat/fp/index.tsx b/console/web/src/components/chat/fp/index.tsx new file mode 100644 index 000000000..2147d92a3 --- /dev/null +++ b/console/web/src/components/chat/fp/index.tsx @@ -0,0 +1,96 @@ +import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView' +import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers' +import type { FunctionCallMessage } from '@/types/chat' +import { + isFpFunction, + isFpTransformFunction, + FP_PIPE_ID, + FP_PREFIX, + unwrapEnvelope, +} from './parsers' +import { PipeView } from './PipeView' +import { UtilView } from './UtilView' + +/** + * fp tool family — the standalone fp worker: `fp::pipe` (the + * worker-side pipeline) and the ten pure `fp::*` transforms + * (get/pick/omit/take/drop/map/filter/split/join/uniq). + */ +export { isFpFunction } from './parsers' + +/** Route genuine `{ error: { kind: 'function_error' } }` envelopes to the + shared error view. Pipe/transform results carry arbitrary threaded JSON, + so skip the parser for `{ content, details }` success envelopes — a value + that happens to look like a denial must not be misread (mirrors state). */ +function guardedErrorDisplay(rawOutput: unknown, running: boolean) { + if (running || rawOutput == null) return null + const isSuccessEnvelope = + typeof rawOutput === 'object' && + !Array.isArray(rawOutput) && + Array.isArray((rawOutput as Record).content) && + 'details' in (rawOutput as Record) + if (isSuccessEnvelope) return null + return parseSandboxErrorDisplay(rawOutput) +} + +/** Branded function-id label, mirroring the other namespace modules. */ +export function FpFunctionIdLabel({ functionId }: { functionId: string }) { + if (!functionId.startsWith(FP_PREFIX)) { + return {functionId} + } + const tail = functionId.slice(FP_PREFIX.length) + return ( + <> + {FP_PREFIX} + {tail} + + ) +} + +function tryRender(message: FunctionCallMessage): React.ReactNode | null { + if (!isFpFunction(message.functionId)) return null + if (message.pendingApproval) return null + + const running = !!message.running + const rawOutput = message.output + const errorDisplay = guardedErrorDisplay(rawOutput, running) + if (errorDisplay) return + + if (message.functionId === FP_PIPE_ID) { + return ( + + ) + } + if (!isFpTransformFunction(message.functionId)) return null + return ( + + ) +} + +/** The pipe is approval-gated by default (its steps run with worker + authority), so the step list IS what an approver reviews — render it as + the preview. */ +function tryRenderPreview( + message: FunctionCallMessage, +): React.ReactNode | null { + if (message.functionId !== FP_PIPE_ID) return null + return +} + +export const FpToolView = { + isFpFunction, + tryRender, + /** Running state is handled inside the views. */ + tryRenderRunning: tryRender, + tryRenderPreview, +} diff --git a/console/web/src/components/chat/fp/parsers.ts b/console/web/src/components/chat/fp/parsers.ts new file mode 100644 index 000000000..0019e47c5 --- /dev/null +++ b/console/web/src/components/chat/fp/parsers.ts @@ -0,0 +1,114 @@ +/** + * Zod schemas + helpers for the fp tool family. + * + * Wire sources: + * workers/fp/src/pipe.rs -> PipeRequest / PipeStep / PipeResponse + * workers/fp/src/util.rs -> per-op requests + UtilResponse { value } + * + * Schemas are non-strict so additive wire fields don't break the UI, and + * optionals are `.nullish()` — serde skips `None` but model-emitted JSON may + * carry explicit nulls. + */ +import { z } from 'zod' +import { unwrapEnvelope } from '@/components/chat/sandbox/parsers' + +export { unwrapEnvelope } + +export const FP_PIPE_ID = 'fp::pipe' +export const FP_PREFIX = 'fp::' + +/** The seventeen pure transforms — an EXPLICIT id set, not a `fp::` prefix + match, so `fp::pipe` and the worker's internal hook function + (`fp::inject-guidance`) never fall into the transform view. */ +export const FP_TRANSFORM_OPS = [ + 'get', + 'pick', + 'omit', + 'take', + 'drop', + 'map', + 'filter', + 'split', + 'join', + 'uniq', + 'size', + 'compact', + 'nth', + 'getOr', + 'flatten', + 'sortBy', + 'reverse', +] as const + +const FP_TRANSFORM_ID_SET: ReadonlySet = new Set( + FP_TRANSFORM_OPS.map((op) => `${FP_PREFIX}${op}`), +) + +export function isFpTransformFunction(id: string): boolean { + return FP_TRANSFORM_ID_SET.has(id) +} + +export function isFpFunction(id: string): boolean { + return id === FP_PIPE_ID || isFpTransformFunction(id) +} + +/** `fp::take` → `take` (label text for the op pill). */ +export function transformOp(id: string): string { + return id.startsWith(FP_PREFIX) ? id.slice(FP_PREFIX.length) : id +} + +/* ---------------- fp::pipe ---------------- */ + +/** `through` is optional here: a gated call's preview input can be a + clipped `arguments_excerpt`. */ +export const pipeStepSchema = z.object({ + function: z.string(), + payload: z.unknown().nullish(), + into: z.string().nullish(), +}) +export type PipeStep = z.infer + +export const pipeRequestSchema = z.object({ + through: z.array(pipeStepSchema).nullish(), + preview_chars: z.number().nullish(), +}) +export type PipeRequest = z.infer + +/** Success details: per-step receipts + a preview, never the value. */ +export const stepReceiptSchema = z.object({ + function: z.string(), + chars: z.number(), +}) +export const pipeResponseSchema = z.object({ + steps: z.array(stepReceiptSchema).nullish(), + value_preview: z.string().nullish(), +}) +export type PipeResponse = z.infer + +/* ---------------- fp transforms ---------------- */ + +/** One loose schema covers all ten lodash-style ops — each op reads only its + own params. */ +export const utilRequestSchema = z.object({ + value: z.unknown().optional(), + path: z.string().nullish(), + paths: z.array(z.string()).nullish(), + n: z.number().nullish(), + separator: z.string().nullish(), + matches: z.record(z.string(), z.unknown()).nullish(), + default: z.unknown().optional(), +}) +export type UtilRequest = z.infer + +/** Success details: the `UtilResponse { value }` wrapper. */ +export const utilResponseSchema = z.object({ value: z.unknown() }) + +/* ---------------- helpers ---------------- */ + +export function safeParseRequest( + schema: z.ZodType, + value: unknown, +): T | null { + const parsed = schema.safeParse(value ?? {}) + return parsed.success ? parsed.data : null +} diff --git a/console/web/src/components/function-call/FunctionCallCard.tsx b/console/web/src/components/function-call/FunctionCallCard.tsx index 2b86abf54..e19c98eaf 100644 --- a/console/web/src/components/function-call/FunctionCallCard.tsx +++ b/console/web/src/components/function-call/FunctionCallCard.tsx @@ -14,6 +14,7 @@ import { HarnessFunctionIdLabel, HarnessToolView, } from '@/components/chat/harness' +import { FpFunctionIdLabel, FpToolView } from '@/components/chat/fp' import { RouterFunctionIdLabel, RouterToolView } from '@/components/chat/router' import { SandboxFunctionIdLabel, @@ -237,6 +238,9 @@ function FunctionIdLabel({ functionId }: { functionId: string }) { if (HarnessToolView.isHarnessFunction(functionId)) { return } + if (FpToolView.isFpFunction(functionId)) { + return + } if (StateToolView.isStateFunction(functionId)) { return } @@ -288,6 +292,7 @@ export function FunctionCallCard({ WorkflowToolView.tryRenderPreview(message) ?? RouterToolView.tryRenderPreview(message) ?? HarnessToolView.tryRenderPreview(message) ?? + FpToolView.tryRenderPreview(message) ?? StateToolView.tryRenderPreview(message) ?? BrowserToolView.tryRenderPreview(message) const customTerminal = !pending @@ -302,6 +307,7 @@ export function FunctionCallCard({ WorkflowToolView.tryRender(message) ?? RouterToolView.tryRender(message) ?? HarnessToolView.tryRender(message) ?? + FpToolView.tryRender(message) ?? StateToolView.tryRender(message) ?? BrowserToolView.tryRender(message)) : null diff --git a/console/web/src/stories/fixtures/fp-fixtures.ts b/console/web/src/stories/fixtures/fp-fixtures.ts new file mode 100644 index 000000000..6d46d8017 --- /dev/null +++ b/console/web/src/stories/fixtures/fp-fixtures.ts @@ -0,0 +1,283 @@ +import type { FunctionCallMessage } from '@/types/chat' + +const now = Date.now() + +function base( + id: string, + functionId: string, + input: unknown, + output?: unknown, + extra?: Partial, +): FunctionCallMessage { + return { + id, + role: 'function-call', + functionId, + input, + ...(output !== undefined ? { output } : {}), + durationMs: 88, + createdAt: now, + ...extra, + } +} + +/** entry-mapper success shape: { content, details }. */ +function resultEnvelope(text: string, details: unknown) { + return { content: [{ type: 'text' as const, text }], details } +} + +/* ---------------- fp::pipe ---------------- */ + +const pipePreview = + '## Circuit breakers\n\nA circuit breaker wraps a remote call and trips open after N consecutive failures, short-circuiting further calls while the downstream recovers. Half-open probes decide when to close it again' + +const pipeThrough = [ + { + function: 'scrapling::fetch', + payload: { + url: 'https://example.com/circuit-breakers', + format: 'markdown', + main_content_only: true, + }, + }, + { function: 'fp::get', payload: { path: '/content' } }, + { function: 'fp::take', payload: { n: 20_000 } }, + { function: 'state::set', payload: { scope: 'research', key: 'article' } }, +] + +const pipeDoneDetails = { + steps: [ + { function: 'scrapling::fetch', chars: 84_213 }, + { function: 'fp::get', chars: 81_902 }, + { function: 'fp::take', chars: 20_000 }, + { function: 'state::set', chars: 46 }, + ], + value_preview: `${pipePreview}…`, +} + +/** The canonical fetch → get → take → set pipeline, with receipts. */ +export const pipeDone = base( + 'pipe-done', + 'fp::pipe', + { through: pipeThrough }, + resultEnvelope(JSON.stringify(pipeDoneDetails), pipeDoneDetails), + { durationMs: 2_431 }, +) + +export const pipeRunning = base( + 'pipe-running', + 'fp::pipe', + { through: pipeThrough }, + undefined, + { running: true, durationMs: undefined }, +) + +/** The step list IS the approval surface — pipe steps run with the fp + worker's authority, so the pipe is approval-gated by default. */ +export const pipePending = base( + 'pipe-pending', + 'fp::pipe', + { through: pipeThrough }, + undefined, + { pendingApproval: true }, +) + +/** Step failure — the handler error message names the step and carries the + completed-step trail. */ +export const pipeStepError = base( + 'pipe-step-error', + 'fp::pipe', + { + through: [ + pipeThrough[0], + { function: 'fp::get', payload: { path: '/body' } }, + ], + }, + { + error: { + kind: 'function_error', + message: + 'pipe failed at step 2 (fp::get): path "/body" matched nothing; available top-level keys: content, status · completed: scrapling::fetch→84213ch', + details: { + message: + 'pipe failed at step 2 (fp::get): path "/body" matched nothing; available top-level keys: content, status · completed: scrapling::fetch→84213ch', + }, + content: [ + { + type: 'text' as const, + text: 'pipe failed at step 2 (fp::get): path "/body" matched nothing; available top-level keys: content, status · completed: scrapling::fetch→84213ch', + }, + ], + }, + }, +) + +/* ---------------- fp transforms ---------------- */ + +/** Direct transform call — success details is the `UtilResponse { value }` + wrapper. */ +export const utilGetDone = base( + 'util-get-done', + 'fp::get', + { + value: { content: `${pipePreview}…`, status: 200 }, + path: '/content', + }, + resultEnvelope(`${pipePreview}…`, { value: `${pipePreview}…` }), +) + +export const utilFilterDone = base( + 'util-filter-done', + 'fp::filter', + { + value: [ + { status: 'active', id: 1 }, + { status: 'gone', id: 2 }, + { status: 'active', id: 3 }, + ], + matches: { status: 'active' }, + }, + resultEnvelope('2 elements', { + value: [ + { status: 'active', id: 1 }, + { status: 'active', id: 3 }, + ], + }), +) + +export const utilTakeRunning = base( + 'util-take-running', + 'fp::take', + { value: `${pipePreview}…`, n: 200 }, + undefined, + { running: true, durationMs: undefined }, +) + +/** The live haiku-4.5 trap: fp::pipe {value} as a seed step — the refusal + redirects to the seed rule. */ +export const pipeSeedError = base( + 'pipe-seed-error', + 'fp::pipe', + { + through: [ + { function: 'fp::pipe', payload: { value: [1, 2, 3, 4, 5] } }, + { function: 'fp::take', payload: { n: 3 } }, + ], + }, + { + error: { + kind: 'function_error', + message: + 'step 1 (fp::pipe): pipes do not nest — to start from a literal value, seed the FIRST transform step\'s payload ("value": …) instead of wrapping the value in a pipe step', + details: { + message: + 'step 1 (fp::pipe): pipes do not nest — to start from a literal value, seed the FIRST transform step\'s payload ("value": …) instead of wrapping the value in a pipe step', + }, + content: [ + { + type: 'text' as const, + text: 'step 1 (fp::pipe): pipes do not nest — to start from a literal value, seed the FIRST transform step\'s payload ("value": …) instead of wrapping the value in a pipe step', + }, + ], + }, + }, +) + +/* ---------------- the seven fp-compat ops ---------------- */ + +/** Number result renders as JSON, not a string pane. */ +export const utilSizeDone = base( + 'util-size-done', + 'fp::size', + { value: { content: `${pipePreview}…`, status: 200 } }, + resultEnvelope('2', { value: 2 }), +) + +/** null-only removal — 0, false and "" survive (deviation from lodash). */ +export const utilCompactDone = base( + 'util-compact-done', + 'fp::compact', + { value: [0, null, false, '', 1, null, 'x'] }, + resultEnvelope('[0,false,"",1,"x"]', { value: [0, false, '', 1, 'x'] }), +) + +/** Negative index chip — the last element JSON Pointer cannot reach. */ +export const utilNthDone = base( + 'util-nth-done', + 'fp::nth', + { + value: [ + { name: 'Alice', score: 92 }, + { name: 'Bob', score: 78 }, + { name: 'Cara', score: 85 }, + ], + n: -1, + }, + resultEnvelope('{"name":"Cara","score":85}', { + value: { name: 'Cara', score: 85 }, + }), +) + +/** Miss → default; the `default` chip renders alongside `path`. */ +export const utilGetOrDone = base( + 'util-getor-done', + 'fp::getOr', + { + value: { content: 'body', status: 200 }, + path: '/etag', + default: 'no-etag', + }, + resultEnvelope('no-etag', { value: 'no-etag' }), +) + +export const utilFlattenDone = base( + 'util-flatten-done', + 'fp::flatten', + { value: [1, [2, 3], [[4]]] }, + resultEnvelope('[1,2,3,[4]]', { value: [1, 2, 3, [4]] }), +) + +export const utilSortByDone = base( + 'util-sortby-done', + 'fp::sortBy', + { + value: [ + { name: 'Alice', score: 92 }, + { name: 'Bob', score: 78 }, + { name: 'Cara', score: 85 }, + ], + path: '/score', + }, + resultEnvelope('3 elements', { + value: [ + { name: 'Bob', score: 78 }, + { name: 'Cara', score: 85 }, + { name: 'Alice', score: 92 }, + ], + }), +) + +export const utilReverseDone = base( + 'util-reverse-done', + 'fp::reverse', + { value: ['first', 'second', 'third'] }, + resultEnvelope('3 elements', { value: ['third', 'second', 'first'] }), +) + +export const fpFixtures = [ + pipeDone, + pipeRunning, + pipePending, + pipeStepError, + pipeSeedError, + utilGetDone, + utilFilterDone, + utilTakeRunning, + utilSizeDone, + utilCompactDone, + utilNthDone, + utilGetOrDone, + utilFlattenDone, + utilSortByDone, + utilReverseDone, +] as const diff --git a/fp/Cargo.lock b/fp/Cargo.lock new file mode 100644 index 000000000..e089b14d9 --- /dev/null +++ b/fp/Cargo.lock @@ -0,0 +1,2182 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "clap" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fp" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "iii-helpers" +version = "0.21.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0d84d5c149ae4404365a79feca28aa66f6a7dbed56423b4b8c4e2421e0b5add" +dependencies = [ + "futures-util", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "reqwest", + "schemars", + "serde", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "iii-sdk" +version = "0.21.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07dd060fddcc9153b0dd07c038a14cf172ce15ce1d4edb98155563ed55b2caba" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "iii-helpers", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.5", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317fafbbe3f02fc663dad00ea6186197de963cd4190e86a26d8d0fae095539af" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/fp/Cargo.toml b/fp/Cargo.toml new file mode 100644 index 000000000..783d575ec --- /dev/null +++ b/fp/Cargo.toml @@ -0,0 +1,29 @@ +[workspace] + +[package] +name = "fp" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "fp" +path = "src/main.rs" + +# Library target so tests/ (required by validate_worker.py for source-changed +# workers) can exercise the public contract; the binary is a thin wiring shim. +[lib] +name = "fp" +path = "src/lib.rs" + +[dependencies] +iii-sdk = "=0.21.6" +# Must stay on the same schemars major as iii-sdk so the derived schemas line up. +schemars = "0.8" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +clap = { version = "4", features = ["derive"] } diff --git a/fp/README.md b/fp/README.md new file mode 100644 index 000000000..66e37f693 --- /dev/null +++ b/fp/README.md @@ -0,0 +1,107 @@ +# fp + +Lodash-style value transforms and `fp::pipe` — worker-side pipelines that +move big values function→function over the iii bus without routing them +through the model. + +## Install + + iii worker add fp + +The worker is a `deploy: binary` Rust worker with no configuration. + +## Why + +An agent that fetches a document and re-types it into the next call's +arguments burns its context window and stalls the provider stream mid-call. +`fp::pipe` runs the whole move in one call — each step triggers a function +and its result lands in the next step's payload — so the value flows +worker→worker and the chat only ever sees per-step sizes and a preview. + +While the worker is connected it also injects a usage section into the agent +system prompt via the harness `pre-generate` hook (`fp::inject-guidance`), +so the guidance is presence-gated: no fp worker, no prompt text. The +binding is one-shot at startup and relies on the engine's recoverable +triggers (iii #1962): bound before the harness is up, it parks as a pending +intent and activates when the harness registers the trigger type. + +## Functions + +| function | lodash | request | +|---|---|---| +| `fp::pipe` | — | `{ through: [{function, payload?, into?}], preview_chars? }` | +| `fp::get` | `_.get` | `{ value, path }` — JSON pointer; a miss names the available keys | +| `fp::pick` | `_.pick` | `{ value, paths }` — subset an object by top-level keys | +| `fp::omit` | `_.omit` | `{ value, paths }` — drop top-level keys | +| `fp::take` | `_.take` | `{ value, n }` — first n array elements / string chars | +| `fp::drop` | `_.drop` | `{ value, n }` — skip the first n | +| `fp::map` | `_.map` | `{ value, path }` — pluck a pointer from each element; misses → null | +| `fp::filter` | `_.filter` | `{ value, matches }` — keep elements matching a partial object | +| `fp::split` | `_.split` | `{ value, separator }` | +| `fp::join` | `_.join` | `{ value, separator? }` (default `,`) | +| `fp::uniq` | `_.uniq` | `{ value }` — dedupe, first occurrences win | +| `fp::size` | `_.size` | `{ value }` — array length / string chars / object key count | +| `fp::compact` | `_.compact` | `{ value }` — remove `null` elements (`0`/`false`/`""` are kept) | +| `fp::nth` | `_.nth` | `{ value, n }` — element at index; negative counts from the end | +| `fp::getOr` | `fp.getOr` | `{ value, path, default }` — pointer value, or `default` on a miss | +| `fp::flatten` | `_.flatten` | `{ value }` — unnest one level | +| `fp::sortBy` | `_.sortBy` | `{ value, path }` — stable ascending sort by a plucked pointer (`""` = the element itself) | +| `fp::reverse` | `_.reverse` | `{ value }` — reversed copy (immutable, fp-style) | + +Transforms take their input at `value` and return `{ value }`. They deviate +from lodash where silence would thread garbage through a pipe: a type +mismatch is an error (not a silent `{}`); a `map` path matching NO element +errors naming what was available (pointers pluck stored fields — no computed +properties like `/length`; `fp::size` counts); `compact` removes only `null` +(lodash's full-falsey removal would eat legitimate `0`/`false`/`""`); +`nth` out of bounds and `size` on a non-collection error instead of returning +`undefined`/`0`; `sortBy` requires every element to carry the key with one +comparable type. Sporadic `map` misses still become `null`, like lodash — +`fp::compact` drops them. + +## The pipe + +```jsonc +fp::pipe { through: [ + { function: "scrapling::fetch", + payload: { url: "https://…", format: "markdown", main_content_only: true } }, + { function: "fp::get", payload: { path: "/content" } }, + { function: "fp::take", payload: { n: 20000 } }, + { function: "state::set", payload: { scope: "research", key: "article" } } +]} +``` + +- Step N's result lands in step N+1's payload at `into` (a JSON pointer, + default `/value` — which is exactly where the transforms and `state::set` + read their input, so most pipes need no `into` at all). +- A transform step threads the transformed value itself — the `{ value }` + wrapper in the function table is only its direct-call response shape, so + there is never a `{value}` layer to unwrap between steps. +- The FIRST step receives no threaded value: start with a producing function + (a fetch, a `state::get`) or seed a leading transform via `payload.value`. + An unseeded leading transform fails validation before anything runs. +- `fp::*` transform steps run inline in this worker; every other step is + one bus trigger with a 120 s budget. The whole pipe must also fit the + caller's own dispatch timeout on `fp::pipe`. +- 1–12 steps. The response is receipts only: `{ steps: [{function, chars}], + value_preview }` (`preview_chars` sizes the preview, default 400, capped at + 8000 — the receipt must never become the bulk channel it replaces). +- A failing step stops the pipe; the error carries the completed-step trail. + +### Boundaries + +- Steps run with THIS worker's authority, not the calling agent's per-step + dispatch policy — the `fp::pipe` call itself is the policy/approval + surface (an approver sees the full step list). For that reason the pipe is + not agent-callable without approval by default; the pure transforms are. + See `iii-permissions.yaml`. +- Statically refused as steps: `shell::*`/`coder::*` (need the harness + fs_scope stamp), `engine::register_trigger`/`engine::unregister_trigger` + (need the harness trusted-session stamp), nested pipes, and every class the + agent policy hard-denies — `session::*`/`approval::*`, + `configuration::*`/`oauth::*` (credentials), `router::*`/`provider::*` + (model spend), `harness::*`/`run::*` (turn control), `stream::*` and bus + internals, `*::on-config-change` — because steps run with worker authority + and must not ride past those denies. `state::*` is the deliberate + exception: persisting the threaded value is the pipe's purpose, and the + pipe call itself is the approval surface. diff --git a/fp/iii-permissions.yaml b/fp/iii-permissions.yaml new file mode 100644 index 000000000..6d9b483ae --- /dev/null +++ b/fp/iii-permissions.yaml @@ -0,0 +1,34 @@ +# Agent permissions for the fp worker. +# Spec: docs/sops/new-worker.md § 7. First-match-wins. +# +# The transforms are pure, side-effect-free value reshapes and are safe for an +# agent to call without approval. `fp::pipe` is deliberately NOT listed: +# its steps run with this worker's (worker-to-worker) authority, outside the +# calling agent's per-step dispatch policy, so it stays at the needs_approval +# default — an approver sees the full step list in the pipe call. Flip it to +# allow here if your deployment wants agents to pipe freely. +# +# NOTE: the policy the checked-in harness actually loads is the repo-root +# iii-permissions.yaml (harness/iii-permissions.yaml symlinks to it), which +# carries these same transform allows. This per-worker copy documents the +# worker's intended posture for other deployments. +version: 1 + +rules: + - fp::get + - fp::pick + - fp::omit + - fp::take + - fp::drop + - fp::map + - fp::filter + - fp::split + - fp::join + - fp::uniq + - fp::size + - fp::compact + - fp::nth + - fp::getOr + - fp::flatten + - fp::sortBy + - fp::reverse diff --git a/fp/iii.worker.yaml b/fp/iii.worker.yaml new file mode 100644 index 000000000..bf6cca6c1 --- /dev/null +++ b/fp/iii.worker.yaml @@ -0,0 +1,7 @@ +iii: v1 +name: fp +language: rust +deploy: binary +manifest: Cargo.toml +bin: fp +description: Lodash-style value transforms (fp::get/pick/take/…) and fp::pipe — worker-side pipelines that move big values function→function without routing them through the model. diff --git a/fp/skills/SKILL.md b/fp/skills/SKILL.md new file mode 100644 index 000000000..1eb4247b5 --- /dev/null +++ b/fp/skills/SKILL.md @@ -0,0 +1,70 @@ +--- +name: fp +description: >- + Move and reshape big values between iii functions without routing them + through the model — fp::pipe worker-side pipelines plus pure + lodash-style transforms (get, pick, omit, take, drop, map, filter, split, + join, uniq). +--- + +# fp + +The fp worker moves bulk data worker→worker. `fp::pipe` runs a short +pipeline in one call: each step triggers a function and its result lands in +the next step's payload at `into` (default `/value`); the caller receives +per-step sizes and a preview — never the value itself. The ten `fp::*` +transforms mirror their lodash namesakes, take input at `value`, and run +inline as pipe steps, threading the transformed value itself onward — their +`{ value }` response wrapper appears only on standalone direct calls (fine +for small values). + +## When to Use + +- Fetch a document and persist it without reading it: `scrapling::fetch` → + `fp::get {path: "/content"}` → `fp::take {n: 20000}` → `state::set`, + all inside one `fp::pipe`. +- Move any big function result into another function's arguments — never + re-type a large value into a call by hand. +- Reshape a list worker-side: `fp::filter {matches}` → `fp::map + {path}` → `fp::uniq` as pipe steps. +- Top-N worker-side: `fp::sortBy {path}` → `fp::reverse` → `fp::take {n}` + as pipe steps. +- Probe a bulk result's shape cheaply: a `fp::get` step with a wrong + `path` fails naming the keys that were available. +- Slice or subset a small value directly: `fp::take`, `fp::pick`, + `fp::omit`, `fp::split`, `fp::join`. + +## Boundaries + +- Pipe steps run with the fp worker's authority, not the calling agent's + per-step dispatch policy, so `fp::pipe` is not agent-callable without + approval by default; the pure transforms are (see iii-permissions.yaml). +- Refused as steps: `shell::*`/`coder::*`, trigger control + (`engine::register_trigger`/`engine::unregister_trigger`), nested pipes, + and the agent-policy hard-denied classes (`session::*`/`approval::*`, + credentials via `configuration::*`/`oauth::*`, model spend via + `router::*`/`provider::*`, turn control via `harness::*`/`run::*`, bus + internals) — call those directly; `state::*` stays allowed on purpose. +- The first pipe step receives no threaded value — start with a producer + (a fetch, `state::get`) or seed a leading transform via `payload.value`. +- 1–12 steps, 120 s per bus step; the whole pipe must fit the caller's + dispatch timeout. Transforms error on type mismatches instead of silently + threading `{}`; `map` errors when a path matches no element (pointers + pluck stored fields, not computed properties like `/length`). + +## Functions + +- `fp::pipe` — `{ through: [{function, payload?, into?}], preview_chars? }` + → `{ steps: [{function, chars}], value_preview }`. +- `fp::get` / `fp::pick` / `fp::omit` — pointer extract / key + subset / key drop. +- `fp::take` / `fp::drop` — first-n / skip-n on strings and arrays. +- `fp::map` / `fp::filter` / `fp::uniq` — pluck / partial-object + match / dedupe on arrays. +- `fp::split` / `fp::join` — string ↔ array. +- `fp::size` / `fp::nth` — count a collection / element at index + (negative counts from the end). +- `fp::getOr` — pointer extract with a `default` on a miss. +- `fp::compact` / `fp::flatten` — drop `null` elements / unnest one level. +- `fp::sortBy` / `fp::reverse` — stable ascending sort by pointer + (`""` = the element itself) / reverse. diff --git a/fp/src/guidance.rs b/fp/src/guidance.rs new file mode 100644 index 000000000..f37bf8147 --- /dev/null +++ b/fp/src/guidance.rs @@ -0,0 +1,255 @@ +//! `fp::inject-guidance` — a `pre_generate` hook that contributes the +//! `fp::pipe` / transform usage guidance to the agent's system prompt, +//! ONLY while this worker is connected. The hook is bound at worker startup; +//! binding order does not matter: the engine parks a binding whose trigger +//! type is not registered yet as a pending intent and activates it when the +//! type appears ("recoverable triggers", engine/src/trigger.rs — this also +//! covers a harness restart, which re-parks and re-activates the binding). +//! The guidance stays presence-gated: without the fp worker the hook +//! function is gone and the fail_open binding contributes nothing. Mirrors +//! web/src/functions/inject_guidance.rs, minus the pre-recoverable-triggers +//! registry-watching rebind dance web still carries. + +use std::sync::Arc; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use iii_sdk::errors::Error; +use iii_sdk::protocol::RegisterTriggerInput; +use iii_sdk::trigger::Trigger; +use iii_sdk::{IIIClient, RegisterFunction}; + +pub const GUIDANCE_HOOK_ID: &str = "fp::inject-guidance"; +pub const GUIDANCE_HOOK_DESC: &str = + "Internal pre_generate hook: appends fp::pipe usage guidance to the agent system \ + prompt. Bound to harness::hook::pre-generate at worker startup; not called directly."; + +/// The harness-PROVIDED trigger type this worker binds to (NOT an engine +/// built-in). +const PRE_GENERATE_TRIGGER_TYPE: &str = "harness::hook::pre-generate"; + +/// The pipe/transform usage guidance appended to the system prompt — the +/// single canonical copy (previously a hand-maintained section in the harness +/// prompt variants). Pure USAGE guidance: the hook only fires when the fp +/// worker is present, so no "look for it / install it" discovery text. +const GUIDANCE: &str = r#"## Moving big values between functions + +HARD RULE: bulk data does not pass through you. A value you will not personally reason about +— a fetched document, a big function result, a list to reshape — moves worker→worker via +`fp::pipe`, never through the chat. Re-typing a big result into another call's arguments +stalls the provider stream mid-call for minutes and has killed live turns, besides burning +the context window; fetching a whole document "to look at it" floods the context for good. + +`fp::pipe` runs the move in ONE call: each step triggers a function and its result lands +in the next step's payload at `into` (default "/value"). Pure transforms run inline as steps — +`fp::{get, pick, omit, take, drop, map, filter, split, join, uniq, size, compact, nth, +getOr, flatten, sortBy, reverse}` with lodash +semantics — their input arrives at `value`, and what they thread onward is the transformed +value itself (the `{value}` wrapper appears only on direct calls, never between steps). +The FIRST step has no previous value to receive: +start with a producing function (a fetch, a state::get) or seed a leading transform via its +payload's `value`. You receive per-step sizes and a preview — never the value itself: + + fp::pipe { through: [ + { function: "scrapling::fetch", + payload: { url: "https://…", format: "markdown", main_content_only: true } }, + { function: "fp::get", payload: { path: "/content" } }, + { function: "fp::take", payload: { n: 20000 } }, + { function: "state::set", payload: { scope: "research", key: "article" } } + ]} + +Before any call that returns or carries bulk data, ask: do I need to READ this, or just +move/reshape/store it? Just moving → pipe. Need a slice to reason over → end the pipe in a +transform and read the receipt's preview (`preview_chars` sizes it, default 400, cap 8000). A small +result you must read in full → a plain direct call (the rule is about bulk, not ceremony) — +but NEVER probe a bulk function directly "to see its result shape": read the response schema +via engine::functions::info, or probe through a pipe (a `fp::get` with a wrong `path` +fails cheaply, naming the keys that WERE available). Plan ONE pipe per outcome: literal +fields in the final step's payload plus `into` compose the stored shape in place — ending in +{ function: "state::set", payload: { scope, key, value: { url } }, into: "/value/content" } +stores { url, content } in one step; don't store, inspect, re-store. +`shell::*`/`coder::*`, trigger-control, session/approval, credential/config, LLM-routing, and +turn-control steps are excluded — call those directly."#; + +/// The slice of the `pre_generate` hook envelope we read (lenient: ignores +/// every other field the harness sends). The harness nests the live +/// generation context under `generate` (see harness +/// `HookRunner::run_pre_generate`). +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct PreGenerateEvent { + #[serde(default)] + pub generate: GenerateContext, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct GenerateContext { + /// The system prompt assembled so far (base + any prior hook's mutation). + #[serde(default)] + pub system_prompt: String, +} + +/// Hook envelope returned to the harness: the mutations to apply to the +/// generation. +#[derive(Debug, Serialize, JsonSchema)] +pub struct PreGenerateResponse { + pub mutations: PreGenerateMutations, +} + +/// The harness applies `system_prompt` only when the key is present +/// (`HookRunner::run_pre_generate` — `if m.system_prompt.is_some()`), so +/// `None` serializes to an empty object: the safe no-op that preserves the +/// harness's assembled prompt. +#[derive(Debug, Default, Serialize, JsonSchema)] +pub struct PreGenerateMutations { + /// Full replacement system prompt (base + appended guidance). The harness + /// overwrites, it does not merge. + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, +} + +/// Build the `pre_generate` mutations for a given base prompt. Pure, so it's +/// unit-testable. +/// +/// Returns NO `system_prompt` when `base` is empty. A missing or renamed +/// `generate.system_prompt` field deserializes to `""` (schema drift), and a +/// fail-open hook must PRESERVE the harness's assembled prompt, never replace +/// it with the guidance alone. For a real, non-empty base we append the +/// guidance and return the FULL prompt. +fn mutations_for(base: &str) -> PreGenerateMutations { + if base.is_empty() { + PreGenerateMutations::default() + } else { + PreGenerateMutations { + system_prompt: Some(format!("{base}\n\n{GUIDANCE}")), + } + } +} + +/// `pre_generate` hook entrypoint: return a `system_prompt` mutation that +/// appends the pipe guidance to a non-empty base. Bound `fail_open`, so an +/// error here never blocks a turn. +async fn handle(event: PreGenerateEvent) -> Result { + Ok(PreGenerateResponse { + mutations: mutations_for(&event.generate.system_prompt), + }) +} + +/// Best-effort trigger binding: a transient failure must not brick boot — it +/// surfaces as a `None` handle. +fn bind(iii: &IIIClient, trigger_type: &str, function_id: &str, config: Value) -> Option { + match iii.register_trigger(RegisterTriggerInput { + trigger_type: trigger_type.to_string(), + function_id: function_id.to_string(), + config, + metadata: None, + }) { + Ok(handle) => { + tracing::info!(trigger_type, function_id, "trigger binding requested"); + Some(handle) + } + Err(e) => { + tracing::warn!(trigger_type, function_id, error = %e, "trigger binding failed"); + None + } + } +} + +/// Register the guidance hook function and bind it to the harness +/// pre-generate trigger type. One shot: if the harness is not up yet, the +/// engine parks the binding as a pending intent and activates it when the +/// type registers (recoverable triggers), so there is nothing to watch or +/// retry. `on_error: fail_open` is MANDATORY: pre_generate defaults +/// fail-CLOSED, which would abort generation if this hook ever errored/timed +/// out; a missing guidance section must never block a turn. +pub fn setup(iii: &Arc) { + iii.register_function( + GUIDANCE_HOOK_ID, + RegisterFunction::new_async( + move |event: PreGenerateEvent| async move { handle(event).await }, + ) + .description(GUIDANCE_HOOK_DESC) + .metadata(json!({ "internal": true })), + ); + + bind( + iii, + PRE_GENERATE_TRIGGER_TYPE, + GUIDANCE_HOOK_ID, + json!({ "on_error": "fail_open" }), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn appends_guidance_after_a_real_base() { + let m = mutations_for("BASE PROMPT"); + let sp = m + .system_prompt + .expect("a non-empty base yields a system_prompt mutation"); + assert!( + sp.starts_with("BASE PROMPT\n\n"), + "the base prompt must be preserved, guidance appended after it" + ); + assert!(sp.contains("fp::pipe"), "guidance content is present"); + } + + #[test] + fn empty_base_emits_no_system_prompt_mutation() { + // A missing/malformed hook payload (system_prompt absent → "") must + // PRESERVE the harness prompt: emit no system_prompt key so the + // harness keeps its own, rather than replacing the whole prompt with + // the guidance alone. The wire shape must stay `{"mutations": {}}` — + // the harness applies system_prompt only when the key is present. + let wire = serde_json::to_value(PreGenerateResponse { + mutations: mutations_for(""), + }) + .expect("response serializes"); + assert_eq!(wire, serde_json::json!({ "mutations": {} })); + } + + /// Mirrors the registry publish gate (`collect_worker_interface.py`): the + /// derived response schema must carry a schema-defining keyword, not the + /// AnyValue schema. + #[test] + fn response_schema_passes_the_publish_typed_gate() { + let schema = schemars::r#gen::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::(); + let value = serde_json::to_value(schema).expect("schema serializes"); + let obj = value.as_object().expect("schema is an object"); + assert!( + ["type", "properties", "$ref"] + .iter() + .any(|k| obj.contains_key(*k)), + "PreGenerateResponse schema is untyped: {value}" + ); + } + + #[test] + fn guidance_mandates_present() { + // The HARD RULE and its teachable edges must survive edits — these + // moved here from the static harness prompts. + for needle in [ + "HARD RULE", + "fp::pipe", + "`into` (default \"/value\")", + "fp::{get, pick, omit, take, drop, map, filter, split, join, uniq, size, compact, nth,", + "getOr, flatten, sortBy, reverse}", + "wrapper appears only on direct calls", + "The FIRST step has no previous value to receive", + "preview_chars", + "the rule is about bulk, not ceremony", + "engine::functions::info", + "ONE pipe per outcome", + "into: \"/value/content\"", + "`shell::*`/`coder::*`", + ] { + assert!(GUIDANCE.contains(needle), "missing: {needle}"); + } + } +} diff --git a/fp/src/lib.rs b/fp/src/lib.rs new file mode 100644 index 000000000..5e3c0bc74 --- /dev/null +++ b/fp/src/lib.rs @@ -0,0 +1,8 @@ +//! fp worker library surface: the ten pure transforms (`util`), the +//! worker-side pipeline (`pipe`), and the system-prompt guidance hook +//! (`guidance`). The `fp` binary (src/main.rs) wires these onto the bus; +//! the lib target exists so `tests/` can exercise the public contract. + +pub mod guidance; +pub mod pipe; +pub mod util; diff --git a/fp/src/main.rs b/fp/src/main.rs new file mode 100644 index 000000000..416d12683 --- /dev/null +++ b/fp/src/main.rs @@ -0,0 +1,189 @@ +//! `fp` binary entry: connect, register the ten transforms + +//! `fp::pipe`, bind the harness pre-generate guidance hook, then sleep +//! until Ctrl+C. + +use std::sync::Arc; + +use anyhow::Result; +use clap::Parser; +use iii_sdk::errors::Error; +use iii_sdk::runtime::WorkerMetadata; +use iii_sdk::{register_worker, IIIClient, InitOptions, RegisterFunction}; + +use fp::{guidance, pipe, util}; + +#[derive(Parser, Debug)] +#[command( + name = "fp", + about = "Lodash-style transforms and worker-side pipelines on the iii bus." +)] +struct Cli { + #[arg(long, default_value = "ws://127.0.0.1:49134")] + url: String, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + let iii = Arc::new(register_worker( + &cli.url, + InitOptions { + metadata: Some(WorkerMetadata { + runtime: "rust".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + name: "fp".to_string(), + os: std::env::consts::OS.to_string(), + description: Some( + "Lodash-style transforms and worker-side pipelines on the iii bus.".to_string(), + ), + pid: Some(std::process::id()), + telemetry: None, + ..WorkerMetadata::default() + }), + ..InitOptions::default() + }, + )); + + register_functions(&iii); + guidance::setup(&iii); + + tracing::info!("fp ready: fp::pipe + 10 transforms + guidance injection"); + tokio::signal::ctrl_c().await?; + tracing::info!("fp shutting down"); + iii.shutdown_async().await; + Ok(()) +} + +fn register_functions(iii: &Arc) { + let engine = iii.clone(); + iii.register_function( + pipe::PIPE_ID, + RegisterFunction::new_async(move |req: pipe::PipeRequest| { + let iii = engine.clone(); + async move { pipe::run(&iii, req).await.map_err(Error::Handler) } + }) + .description(pipe::PIPE_DESC), + ); + + register_util(iii, util::GET_ID, util::GET_DESC, |r: util::GetRequest| { + util::get(&r.value, &r.path) + }); + register_util( + iii, + util::PICK_ID, + util::PICK_DESC, + |r: util::PickRequest| util::pick(r.value, &r.paths), + ); + register_util( + iii, + util::OMIT_ID, + util::OMIT_DESC, + |r: util::OmitRequest| util::omit(r.value, &r.paths), + ); + register_util( + iii, + util::TAKE_ID, + util::TAKE_DESC, + |r: util::TakeRequest| util::take(r.value, r.n as usize), + ); + register_util( + iii, + util::DROP_ID, + util::DROP_DESC, + |r: util::DropRequest| util::drop(r.value, r.n as usize), + ); + register_util(iii, util::MAP_ID, util::MAP_DESC, |r: util::MapRequest| { + util::map(r.value, &r.path) + }); + register_util( + iii, + util::FILTER_ID, + util::FILTER_DESC, + |r: util::FilterRequest| util::filter(r.value, &r.matches), + ); + register_util( + iii, + util::SPLIT_ID, + util::SPLIT_DESC, + |r: util::SplitRequest| util::split(r.value, &r.separator), + ); + register_util( + iii, + util::JOIN_ID, + util::JOIN_DESC, + |r: util::JoinRequest| util::join(r.value, &r.separator), + ); + register_util( + iii, + util::UNIQ_ID, + util::UNIQ_DESC, + |r: util::ValueRequest| util::uniq(r.value), + ); + register_util( + iii, + util::SIZE_ID, + util::SIZE_DESC, + |r: util::ValueRequest| util::size(&r.value), + ); + register_util( + iii, + util::COMPACT_ID, + util::COMPACT_DESC, + |r: util::ValueRequest| util::compact(r.value), + ); + register_util(iii, util::NTH_ID, util::NTH_DESC, |r: util::NthRequest| { + util::nth(r.value, r.n) + }); + register_util( + iii, + util::GET_OR_ID, + util::GET_OR_DESC, + |r: util::GetOrRequest| util::get_or(&r.value, &r.path, r.default), + ); + register_util( + iii, + util::FLATTEN_ID, + util::FLATTEN_DESC, + |r: util::ValueRequest| util::flatten(r.value), + ); + register_util( + iii, + util::SORT_BY_ID, + util::SORT_BY_DESC, + |r: util::SortByRequest| util::sort_by(r.value, &r.path), + ); + register_util( + iii, + util::REVERSE_ID, + util::REVERSE_DESC, + |r: util::ValueRequest| util::reverse(r.value), + ); +} + +/// Register one transform: parse the typed request, run the pure core, wrap +/// the result as `UtilResponse { value }` (typed response schema). +fn register_util(iii: &Arc, id: &'static str, description: &'static str, core: F) +where + Req: serde::de::DeserializeOwned + schemars::JsonSchema + Send + 'static, + F: Fn(Req) -> std::result::Result + Send + Sync + Clone + 'static, +{ + iii.register_function( + id, + RegisterFunction::new_async(move |req: Req| { + let core = core.clone(); + async move { + core(req) + .map(|value| util::UtilResponse { value }) + .map_err(Error::Handler) + } + }) + .description(description), + ); +} diff --git a/fp/src/pipe.rs b/fp/src/pipe.rs new file mode 100644 index 000000000..6ae6518cb --- /dev/null +++ b/fp/src/pipe.rs @@ -0,0 +1,537 @@ +//! `fp::pipe` — a short worker-side pipeline: each step triggers a +//! function and its result lands in the next step's payload (`into`, default +//! `/value`). Pure `fp::*` transforms run inline; every other step goes +//! over the bus via `iii.trigger`. Large values thread worker→worker and +//! never round-trip through the model; the chat gets a receipt with per-step +//! sizes and a preview. +//! +//! Steps run with THIS worker's authority, not the calling agent's dispatch +//! policy — the `fp::pipe` call itself is the policy/approval surface +//! (an approver sees the full step list). `forbidden_step` statically refuses +//! the classes that must never ride on worker authority. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use iii_sdk::protocol::TriggerRequest; +use iii_sdk::IIIClient; + +use crate::util; + +pub const PIPE_ID: &str = "fp::pipe"; +pub const PIPE_DESC: &str = + "Run a short worker-side pipeline in one call: each step triggers a function and its result \ + lands in the next step's payload at `into` (default \"/value\") — large values flow \ + worker→worker and never enter the chat. fp::* transform steps \ + (get/pick/omit/take/drop/map/filter/split/join/uniq/size/compact/nth/getOr/flatten/\ + sortBy/reverse, lodash semantics) run inline and \ + thread the transformed value itself — the {value} wrapper they return when called \ + directly never appears between steps. \ + The FIRST step receives no threaded value: start with a producing function (a fetch, a \ + state::get) or seed a leading transform via payload.value. \ + Example: fetch an article and persist it trimmed: \ + through=[{function:'scrapling::fetch', payload:{url,format:'markdown'}}, \ + {function:'fp::get', payload:{path:'/content'}}, \ + {function:'fp::take', payload:{n:20000}}, \ + {function:'state::set', payload:{scope,key}}]. Returns per-step sizes and a preview, not \ + the value."; + +/// ponytail: v1 ceilings — no shell/coder steps (they need the harness +/// fs_scope stamp), no nested pipes, no trigger-control or session/approval +/// steps, and a held (approval) release re-runs steps from scratch is +/// unsupported: the pipe call itself is what approvers review. +const MAX_STEPS: usize = 12; +const DEFAULT_PREVIEW_CHARS: usize = 400; +/// Hard cap on `preview_chars`: the receipt is a glimpse (or a slice to +/// reason over), never the bulk channel it exists to replace — an unbounded +/// u32 would let one call ship an entire fetched document back into the chat. +const MAX_PREVIEW_CHARS: usize = 8_000; +/// Per-step bus budget. The WHOLE pipe must also fit the caller's own +/// dispatch timeout on `fp::pipe`. +const STEP_TIMEOUT_MS: u64 = 120_000; + +// NOTE: deliberately NOT `#[serde(deny_unknown_fields)]`. The engine injects +// a `_caller_worker_id` field into every dispatched call's payload (see +// shell/src/functions/types.rs for the same note), so a strict top-level +// struct would reject EVERY live call. `PipeStep` below stays strict — the +// engine never injects inside nested values, and a typo'd step field failing +// loudly is teachable. (A `//` comment on purpose: a `///` doc comment +// becomes the published schema description, leaking engine plumbing to every +// model that reads engine::functions::info.) +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PipeRequest { + /// Steps run in order; step N+1 receives step N's threaded value. + pub through: Vec, + /// Preview length of the final threaded value in the receipt (capped at + /// 8000). + #[serde(default)] + pub preview_chars: Option, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PipeStep { + /// Function id to trigger (e.g. `scrapling::fetch`, `fp::get`, + /// `state::set`). + pub function: String, + /// Base payload for the call. + #[serde(default)] + pub payload: Option, + /// JSON pointer in THIS step's payload where the previous step's value + /// lands (default `/value`). Object keys only (`~0`/`~1` escapes are + /// decoded); indexing into arrays is unsupported. + #[serde(default)] + pub into: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct StepReceipt { + pub function: String, + /// Size of the value threaded OUT of this step (chars of its string or + /// serialized form). + pub chars: usize, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct PipeResponse { + pub steps: Vec, + pub value_preview: Option, +} + +/// Step functions a pipe refuses. Steps run with THIS worker's authority, so +/// every class the agent policy hard-denies (iii-permissions.yaml) must be +/// refused here too — an allowlisted or approve-always `fp::pipe` would +/// otherwise be a standing policy bypass. `state::*` is the deliberate +/// exception: persisting the threaded value is the pipe's purpose, and the +/// pipe call itself is the approval surface an approver reviews. +fn forbidden_step(function_id: &str) -> Option<&'static str> { + if (function_id.starts_with("shell::") && !function_id.starts_with("shell::filesystem::")) + || function_id.starts_with("coder::") + { + return Some("filesystem-scoped functions (shell::*/coder::*) are not supported in a pipe — call them directly"); + } + if function_id == PIPE_ID { + return Some( + "pipes do not nest — to start from a literal value, seed the FIRST transform \ + step's payload (\"value\": …) instead of wrapping the value in a pipe step", + ); + } + if function_id == "engine::register_trigger" || function_id == "engine::unregister_trigger" { + return Some("trigger control calls are not supported in a pipe — call them directly"); + } + if function_id.starts_with("session::") || function_id.starts_with("approval::") { + return Some( + "session/approval functions are not supported in a pipe — steps run with worker \ + authority, which would bypass the agent-level deny on them", + ); + } + if function_id.starts_with("configuration::") || function_id.starts_with("oauth::") { + return Some( + "credential/config functions are not supported in a pipe — steps run with worker \ + authority, which would bypass the agent-level deny on them", + ); + } + if function_id.starts_with("router::") || function_id.starts_with("provider::") { + return Some( + "LLM routing/provider functions are not supported in a pipe — model spend must \ + stay under the harness loop's accounting", + ); + } + if function_id.starts_with("harness::") || function_id.starts_with("run::") { + return Some( + "harness/turn-control functions are not supported in a pipe — steps run with \ + worker authority, which would bypass the agent-level deny on them", + ); + } + if function_id.starts_with("stream::") + || function_id.starts_with("hook-fanout::") + || function_id.starts_with("iii::") + { + return Some("bus-internal functions are not supported in a pipe"); + } + if function_id.ends_with("::on-config-change") { + return Some("lifecycle hooks are engine-dispatched, never pipe steps"); + } + None +} + +/// Static validation, separated from execution for testability. +pub fn validate(req: &PipeRequest) -> Result<(), String> { + if req.through.is_empty() || req.through.len() > MAX_STEPS { + return Err(format!("a pipe takes 1..={MAX_STEPS} steps")); + } + for (i, step) in req.through.iter().enumerate() { + if let Some(reason) = forbidden_step(&step.function) { + return Err(format!("step {} ({}): {reason}", i + 1, step.function)); + } + } + // Fail fast on an unseeded leading transform (live-tested trap: serde's + // bare "missing field `value`" taught nothing). Only our own transforms + // are statically checkable; a foreign first step just runs. + if let Some(first) = req.through.first() { + if util::is_transform(&first.function) + && first + .payload + .as_ref() + .and_then(|p| p.get("value")) + .is_none() + { + return Err(format!( + "step 1 ({}) receives no threaded input — seed it in the first step's payload \ + ({{\"value\": …}}), or start the pipe with a producing function (a fetch, a \ + state::get)", + first.function + )); + } + } + Ok(()) +} + +/// Set `value` at `pointer` inside `payload`, creating intermediate objects. +fn inject(payload: Value, pointer: &str, value: Value) -> Result { + // RFC 6901 token unescape (`~1` → `/`, then `~0` → `~`). Object keys + // only — a token that lands on an array errors below instead of indexing. + let parts: Vec = pointer + .strip_prefix('/') + .map(|p| { + p.split('/') + .map(|t| t.replace("~1", "/").replace("~0", "~")) + .collect() + }) + .unwrap_or_default(); + if parts.is_empty() || parts.iter().any(|p| p.is_empty()) { + return Err(format!( + "`into` must be a JSON pointer like \"/value\", got {pointer:?}" + )); + } + let mut root = match payload { + Value::Object(m) => Value::Object(m), + Value::Null => json!({}), + other => { + return Err(format!( + "step payload must be an object to receive `into`, got {}", + util::kind_of(&other) + )) + } + }; + let mut cursor = &mut root; + for part in &parts[..parts.len() - 1] { + let map = cursor.as_object_mut().expect("object cursor"); + cursor = map.entry(part.clone()).or_insert_with(|| json!({})); + if !cursor.is_object() { + return Err(format!( + "`into` path {pointer:?} crosses a non-object at {part:?}" + )); + } + } + let map = cursor.as_object_mut().expect("object cursor"); + map.insert(parts[parts.len() - 1].clone(), value); + Ok(root) +} + +/// Requested preview length, clamped to [0, MAX_PREVIEW_CHARS]. +fn preview_len(requested: Option) -> usize { + requested + .map(|c| c as usize) + .unwrap_or(DEFAULT_PREVIEW_CHARS) + .min(MAX_PREVIEW_CHARS) +} + +fn chars_of(value: &Value) -> usize { + match value { + Value::String(s) => s.chars().count(), + other => other.to_string().chars().count(), + } +} + +fn preview_of(value: &Value, max_chars: usize) -> String { + let s = match value { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + match s.char_indices().nth(max_chars) { + Some((byte_idx, _)) => format!("{}…", &s[..byte_idx]), + None => s, + } +} + +/// A step failure carries the receipts trail so the caller sees how far the +/// pipe got and at what sizes, without the values themselves. `i` is the +/// 0-based loop index; the message is 1-based to match the console's step +/// list (live-tested: 0-based "failed at step 2" next to a 2-step completed +/// trail read as "the 2nd step failed" when it was the 3rd). +fn step_error(i: usize, function: &str, msg: &str, receipts: &[StepReceipt]) -> String { + let n = i + 1; + let trail = receipts + .iter() + .map(|r| format!("{}→{}ch", r.function, r.chars)) + .collect::>() + .join(" · "); + if trail.is_empty() { + format!("pipe failed at step {n} ({function}): {msg}") + } else { + format!("pipe failed at step {n} ({function}): {msg} · completed: {trail}") + } +} + +/// Execute a pipe: transforms inline, everything else over the bus. The first +/// failing step stops the pipe with a teachable error carrying the trail. +pub async fn run(iii: &IIIClient, req: PipeRequest) -> Result { + validate(&req)?; + let preview_chars = preview_len(req.preview_chars); + + let mut receipts: Vec = Vec::with_capacity(req.through.len()); + let mut piped: Option = None; + + for (i, step) in req.through.iter().enumerate() { + let mut args = step.payload.clone().unwrap_or_else(|| json!({})); + if let Some(value) = piped.take() { + let pointer = step.into.as_deref().unwrap_or("/value"); + args = inject(args, pointer, value) + .map_err(|msg| step_error(i, &step.function, &msg, &receipts))?; + } + + let value = match util::apply(&step.function, &args) { + Some(result) => result.map_err(|msg| step_error(i, &step.function, &msg, &receipts))?, + None => iii + .trigger(TriggerRequest { + function_id: step.function.clone(), + payload: args, + action: None, + timeout_ms: Some(STEP_TIMEOUT_MS), + }) + .await + .map_err(|e| step_error(i, &step.function, &e.to_string(), &receipts))?, + }; + + receipts.push(StepReceipt { + function: step.function.clone(), + chars: chars_of(&value), + }); + piped = Some(value); + } + + let final_value = piped.unwrap_or(Value::Null); + // Receipts + preview only, matching PipeResponse — a step's raw result + // (e.g. state::set echoing the stored value) must never ride along, or + // the receipt grows to the size of the value it exists to avoid. + Ok(PipeResponse { + value_preview: Some(preview_of(&final_value, preview_chars)), + steps: receipts, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(v: Value) -> Result { + serde_json::from_value(v).map_err(|e| e.to_string()) + } + + #[test] + fn tolerates_the_engine_injected_caller_worker_id() { + // The engine stamps `_caller_worker_id` into every dispatched payload + // (see shell/src/functions/types.rs) — this is the byte-shape of the + // live request a stale deny_unknown_fields build rejected 4× in a row + // (2026-07-16 research-pipeline session), forcing an 18KB + // bulk-through-chat fallback: + let req = parse(json!({ + "preview_chars": 300, + "through": [ + { "function": "scrapling::fetch", + "payload": { "url": "https://en.wikipedia.org/wiki/Circuit_breaker_design_pattern", + "format": "markdown", "main_content_only": true } }, + { "function": "fp::get", "payload": { "path": "/content" } }, + { "function": "fp::take", "payload": { "n": 6000 } }, + { "function": "state::set", "into": "/value/content", + "payload": { "scope": "research", "key": "article", + "value": { "url": "https://en.wikipedia.org/wiki/Circuit_breaker_design_pattern", + "title": "Circuit Breaker Design Pattern", + "content": "" } } }, + ], + "_caller_worker_id": "harness", + })) + .unwrap(); + assert_eq!(req.through.len(), 4); + assert!(validate(&req).is_ok()); + + // ...while STEP-level strictness stays: the engine never injects + // inside nested values, so a typo'd step field still fails loudly. + assert!( + parse(json!({ "through": [{ "function": "a::b", "args": {} }] })) + .unwrap_err() + .contains("args") + ); + } + + #[test] + fn unseeded_leading_transform_fails_fast_and_teachably() { + // The exact live shape that failed with serde's bare "missing field + // `value`": a transform first, its seed nowhere. + let req = parse(json!({ "through": [ + { "function": "fp::split", "payload": { "separator": "," } }, + { "function": "fp::uniq" }, + ]})) + .unwrap(); + let err = validate(&req).unwrap_err(); + // step indices are 1-based in every message, matching the console + assert!(err.contains("step 1") && err.contains("no threaded input")); + assert!(err.contains("producing function")); + + // seeded leading transform passes (explicit null seed counts: the + // transform itself decides whether null is workable) + for seed in [json!("a,b"), Value::Null] { + let req = parse(json!({ "through": [ + { "function": "fp::split", "payload": { "separator": ",", "value": seed } }, + ]})) + .unwrap(); + assert!(validate(&req).is_ok()); + } + + // a producing first step is not ours to check + let req = parse(json!({ "through": [ + { "function": "scrapling::fetch", "payload": { "url": "u" } }, + { "function": "fp::uniq" }, + ]})) + .unwrap(); + assert!(validate(&req).is_ok()); + } + + #[test] + fn validate_checks_shape_and_forbidden_steps() { + // fetch → get → take → set: the motivating pipeline parses, zero `into`. + let req = parse(json!({ "through": [ + { "function": "scrapling::fetch", "payload": { "url": "u", "format": "markdown" } }, + { "function": "fp::get", "payload": { "path": "/content" } }, + { "function": "fp::take", "payload": { "n": 20000 } }, + { "function": "state::set", "payload": { "scope": "s", "key": "k" } }, + ]})) + .unwrap(); + assert_eq!(req.through.len(), 4); + assert!(req.through.iter().all(|s| s.into.is_none())); + assert!(validate(&req).is_ok()); + + // unknown step fields fail loudly, not silently + assert!(parse(json!({ "through": [ + { "function": "a::b", "pick": "/content" }, + ]})) + .unwrap_err() + .contains("pick")); + + // scoped / control / nested / worker-authority steps are refused with + // a reason — mirroring every agent-policy hard-denied class + // (iii-permissions.yaml), state::* excepted on purpose + for (function, needle) in [ + ("shell::exec", "filesystem-scoped"), + ("fp::pipe", "nest"), + ("engine::register_trigger", "trigger control"), + ("session::append", "worker"), + ("approval::resolve", "worker"), + ("configuration::get", "credential"), + ("oauth::anthropic::login", "credential"), + ("router::chat", "routing"), + ("provider::anthropic::chat", "routing"), + ("harness::send", "turn-control"), + ("run::start", "turn-control"), + ("stream::set", "bus-internal"), + ("iii::durable::publish", "bus-internal"), + ("storage::on-config-change", "lifecycle"), + ] { + let req = parse(json!({ "through": [{ "function": function }] })).unwrap(); + assert!(validate(&req).unwrap_err().contains(needle), "{function}"); + } + + let empty = parse(json!({ "through": [] })).unwrap(); + assert!(validate(&empty).is_err()); + } + + #[test] + fn pipe_as_seed_step_redirects_to_the_seed_rule() { + // Live-tested (haiku-4.5): five consecutive attempts used fp::pipe + // {value} as step 1 to inject a literal starting value — the refusal + // must teach the seed rule, not just state the nesting ban. + let req = parse(json!({ "through": [ + { "function": "fp::pipe", "payload": { "value": [1, 2, 3] } }, + { "function": "fp::take", "payload": { "n": 3 } }, + ]})) + .unwrap(); + let err = validate(&req).unwrap_err(); + assert!(err.contains("do not nest") && err.contains("seed the FIRST transform")); + } + + #[test] + fn inject_shapes_the_value() { + // inject creates intermediate objects and rejects bad pointers + assert_eq!( + inject(json!({ "scope": "s" }), "/value", json!("x")).unwrap(), + json!({ "scope": "s", "value": "x" }) + ); + assert_eq!( + inject(json!({}), "/config/value", json!(1)).unwrap(), + json!({ "config": { "value": 1 } }) + ); + // schema-literal models pad steps with a placeholder ({"value": {}} + // — the published schemas mark `value` required); the threaded value + // must overwrite it (live-tested: ornith-35b padded every join step) + assert_eq!( + inject( + json!({ "separator": "-", "value": {} }), + "/value", + json!(["a"]) + ) + .unwrap(), + json!({ "separator": "-", "value": ["a"] }) + ); + assert!(inject(json!({}), "value", json!(1)).is_err()); + assert!(inject(json!({ "a": 1 }), "/a/b", json!(1)).is_err()); + + // RFC 6901 escapes decode per token; arrays refuse teachably + assert_eq!( + inject(json!({}), "/headers/x~1trace", json!(1)).unwrap(), + json!({ "headers": { "x/trace": 1 } }) + ); + assert_eq!( + inject(json!({}), "/a~0b", json!(1)).unwrap(), + json!({ "a~b": 1 }) + ); + assert!(inject(json!({ "items": [] }), "/items/0", json!(1)) + .unwrap_err() + .contains("non-object")); + } + + #[test] + fn previews_and_errors_stay_small() { + assert_eq!(preview_of(&json!("abcdef"), 3), "abc…"); + assert_eq!(preview_of(&json!("ab"), 3), "ab"); + + // an unbounded preview would defeat the receipt design — clamp it + assert_eq!(preview_len(None), DEFAULT_PREVIEW_CHARS); + assert_eq!(preview_len(Some(50)), 50); + assert_eq!(preview_len(Some(u32::MAX)), MAX_PREVIEW_CHARS); + + let receipts = vec![ + StepReceipt { + function: "scrapling::fetch".into(), + chars: 184_232, + }, + StepReceipt { + function: "fp::get".into(), + chars: 180_101, + }, + ]; + // loop index 2 = the third step; the message is 1-based like the UI + let msg = step_error(2, "state::set", "boom", &receipts); + assert!(msg.contains("step 3 (state::set): boom")); + assert!(msg.contains("scrapling::fetch→184232ch")); + } + + #[test] + fn desc_teaches_bare_value_threading() { + // The live trap: a model that saw {value: X} from ten direct calls + // composed `pick {paths:["value"]}` to unwrap between steps. The + // published description must keep saying nothing threads wrapped. + assert!(PIPE_DESC.contains("thread the transformed value itself")); + } +} diff --git a/fp/src/util.rs b/fp/src/util.rs new file mode 100644 index 000000000..069bebc0d --- /dev/null +++ b/fp/src/util.rs @@ -0,0 +1,901 @@ +//! `fp::*` — pure lodash-style value transforms +//! (). Registered for discovery and direct +//! calls; as `fp::pipe` steps they execute inline in this worker (no bus +//! round-trip). + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +pub const GET_ID: &str = "fp::get"; +pub const GET_DESC: &str = + "Extract the single value at a JSON pointer (lodash _.get): { value, path: \"/content\" }. \ + A miss names the keys that were available. Mainly useful as a fp::pipe step."; + +pub const PICK_ID: &str = "fp::pick"; +pub const PICK_DESC: &str = + "Subset an object by top-level keys (lodash _.pick): { value, paths: [\"a\",\"b\"] } -> \ + {a, b}; missing keys are omitted. Mainly useful as a fp::pipe step."; + +pub const OMIT_ID: &str = "fp::omit"; +pub const OMIT_DESC: &str = + "Drop top-level keys from an object (lodash _.omit): { value, paths: [\"a\",\"b\"] }. \ + Mainly useful as a fp::pipe step."; + +pub const TAKE_ID: &str = "fp::take"; +pub const TAKE_DESC: &str = + "Keep the first n elements of an array or the first n characters of a string (lodash \ + _.take): { value, n }. Mainly useful as a fp::pipe step."; + +pub const DROP_ID: &str = "fp::drop"; +pub const DROP_DESC: &str = + "Skip the first n elements of an array or the first n characters of a string (lodash \ + _.drop): { value, n }. Mainly useful as a fp::pipe step."; + +pub const MAP_ID: &str = "fp::map"; +pub const MAP_DESC: &str = + "Pluck the value at a JSON pointer from each array element (lodash _.map with a property \ + iteratee): { value, path: \"/id\" }. Sporadic misses become null (fp::compact drops \ + them), but a path matching NO element errors — pointers pluck stored fields, not \ + computed properties like /length (fp::size counts). Mainly useful as a fp::pipe step."; + +pub const FILTER_ID: &str = "fp::filter"; +pub const FILTER_DESC: &str = + "Keep array elements whose properties equal a partial object (lodash _.filter with a \ + matches iteratee): { value, matches: { status: \"active\" } }. matches is a partial \ + OBJECT (never a bare string/number) and only object elements can match. Mainly useful \ + as a fp::pipe step."; + +pub const SPLIT_ID: &str = "fp::split"; +pub const SPLIT_DESC: &str = + "Split a string into an array of strings (lodash _.split): { value, separator: \"\\n\" }. \ + Mainly useful as a fp::pipe step."; + +pub const JOIN_ID: &str = "fp::join"; +pub const JOIN_DESC: &str = + "Join array elements into one string (lodash _.join): { value, separator } (separator \ + defaults to \",\"). Mainly useful as a fp::pipe step."; + +pub const UNIQ_ID: &str = "fp::uniq"; +pub const UNIQ_DESC: &str = + "Deduplicate an array, keeping first occurrences (lodash _.uniq): { value }. Mainly useful \ + as a fp::pipe step."; + +pub const SIZE_ID: &str = "fp::size"; +pub const SIZE_DESC: &str = + "Count a collection (lodash _.size): array length, string chars, or object key count: \ + { value }. Non-collections error instead of returning 0. Mainly useful as a fp::pipe step."; + +pub const COMPACT_ID: &str = "fp::compact"; +pub const COMPACT_DESC: &str = + "Remove null elements from an array (lodash _.compact, null-only: 0, false and \"\" are \ + KEPT so plucked data survives): { value }. Pairs with fp::map, whose sporadic misses \ + become null. Mainly useful as a fp::pipe step."; + +pub const NTH_ID: &str = "fp::nth"; +pub const NTH_DESC: &str = + "Element at index n; negative n counts from the end (lodash _.nth): { value, n: -1 } is \ + the last element. Out of bounds errors naming the length. Mainly useful as a fp::pipe \ + step."; + +pub const GET_OR_ID: &str = "fp::getOr"; +pub const GET_OR_DESC: &str = + "Extract the value at a JSON pointer, or `default` when the path misses (lodash fp.getOr): \ + { value, path, default }. A stored null is a hit, not a miss. Mainly useful as a fp::pipe \ + step."; + +pub const FLATTEN_ID: &str = "fp::flatten"; +pub const FLATTEN_DESC: &str = + "Flatten an array one level (lodash _.flatten): [1,[2],[[3]]] -> [1,2,[3]]. Mainly useful \ + as a fp::pipe step."; + +pub const SORT_BY_ID: &str = "fp::sortBy"; +pub const SORT_BY_DESC: &str = + "Sort array elements ascending by the value at a JSON pointer (lodash _.sortBy, stable): \ + { value, path } — path \"\" sorts the elements themselves (primitives). Keys must all be \ + numbers, strings, or booleans; an element missing the path errors. Pair with fp::reverse \ + for descending. Mainly useful as a fp::pipe step."; + +pub const REVERSE_ID: &str = "fp::reverse"; +pub const REVERSE_DESC: &str = + "Reverse an array (lodash _.reverse, immutable like lodash/fp): { value }. Mainly useful \ + as a fp::pipe step."; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetRequest { + /// Input value (a pipe lands the previous step's value here). + pub value: Value, + /// JSON pointer selecting the value to return (e.g. `/content`). + pub path: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PickRequest { + /// Input value (a pipe lands the previous step's value here). + pub value: Value, + /// Top-level keys to keep; missing keys are silently omitted. + pub paths: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct OmitRequest { + /// Input value (a pipe lands the previous step's value here). + pub value: Value, + /// Top-level keys to drop; missing keys are ignored. + pub paths: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TakeRequest { + /// Input value (a pipe lands the previous step's value here). + pub value: Value, + /// How many array elements / string characters to keep. + pub n: u32, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DropRequest { + /// Input value (a pipe lands the previous step's value here). + pub value: Value, + /// How many array elements / string characters to skip. + pub n: u32, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MapRequest { + /// Input value (a pipe lands the previous step's value here). + pub value: Value, + /// JSON pointer plucked from each element (e.g. `/id`). Sporadic misses + /// become null; a path matching NO element is an error (stored fields + /// only — no computed properties like `/length`). + pub path: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct FilterRequest { + /// Input value (a pipe lands the previous step's value here). + pub value: Value, + /// Partial object an element's top-level properties must equal. + pub matches: Map, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SplitRequest { + /// Input value (a pipe lands the previous step's value here). + pub value: Value, + /// Non-empty separator to split on (e.g. `"\n"`). + pub separator: String, +} + +fn default_separator() -> String { + ",".into() +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct JoinRequest { + /// Input value (a pipe lands the previous step's value here). + pub value: Value, + /// Separator between elements (default `","`). + #[serde(default = "default_separator")] + pub separator: String, +} + +/// Shared request for the value-only ops (uniq/size/compact/flatten/reverse). +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ValueRequest { + /// Input value (a pipe lands the previous step's value here). + pub value: Value, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct NthRequest { + /// Input value (a pipe lands the previous step's value here). + pub value: Value, + /// Index to pluck; negative counts from the end (`-1` = last element). + pub n: i64, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetOrRequest { + /// Input value (a pipe lands the previous step's value here). + pub value: Value, + /// JSON pointer selecting the value to return (e.g. `/content`). + pub path: String, + /// Returned when the path matches nothing (a stored null is a hit). + pub default: Value, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SortByRequest { + /// Input value (a pipe lands the previous step's value here). + pub value: Value, + /// JSON pointer plucked from each element as the sort key (e.g. `/score`). + pub path: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct UtilResponse { + pub value: Value, +} + +pub fn get(value: &Value, path: &str) -> Result { + match value.pointer(path) { + Some(v) => Ok(v.clone()), + None => Err(format!( + "path {path:?} matched nothing; available top-level keys: {}", + top_level_keys(value) + )), + } +} + +// Deviates from lodash on non-objects: erroring beats silently threading `{}` +// through the rest of a pipe. +// ponytail: top-level keys only; deep paths when a real pipeline needs them. +pub fn pick(value: Value, paths: &[String]) -> Result { + match value { + Value::Object(mut map) => Ok(Value::Object( + paths + .iter() + .filter_map(|k| map.remove(k).map(|v| (k.clone(), v))) + .collect::>(), + )), + other => Err(format!( + "pick needs an object value (got {})", + kind_of(&other) + )), + } +} + +pub fn omit(value: Value, paths: &[String]) -> Result { + match value { + Value::Object(mut map) => { + for k in paths { + map.remove(k); + } + Ok(Value::Object(map)) + } + other => Err(format!( + "omit needs an object value (got {})", + kind_of(&other) + )), + } +} + +pub fn take(value: Value, n: usize) -> Result { + match value { + Value::String(s) => Ok(Value::String(match s.char_indices().nth(n) { + Some((byte_idx, _)) => s[..byte_idx].to_string(), + None => s, + })), + Value::Array(mut items) => { + items.truncate(n); + Ok(Value::Array(items)) + } + other => Err(format!( + "take needs a string or array value (got {}); use fp::get to select one first", + kind_of(&other) + )), + } +} + +pub fn drop(value: Value, n: usize) -> Result { + match value { + Value::String(s) => Ok(Value::String(match s.char_indices().nth(n) { + Some((byte_idx, _)) => s[byte_idx..].to_string(), + None => String::new(), + })), + Value::Array(items) => Ok(Value::Array(items.into_iter().skip(n).collect())), + other => Err(format!( + "drop needs a string or array value (got {}); use fp::get to select one first", + kind_of(&other) + )), + } +} + +// Misses become null, mirroring lodash's undefined — a few holes in a mapped +// list are recoverable downstream; failing the whole pipe on one is not. +// A path matching NO element is different: that is a wrong path (live-tested: +// a model tried lodash's computed `/length` on strings and would have threaded +// all-nulls through the rest of the pipe), so it errors teachably instead. +pub fn map(value: Value, path: &str) -> Result { + match value { + Value::Array(items) => { + let mut misses = 0usize; + let plucked: Vec = items + .iter() + .map(|el| match el.pointer(path) { + Some(v) => v.clone(), + None => { + misses += 1; + Value::Null + } + }) + .collect(); + if misses == items.len() && !items.is_empty() { + let hint = match &items[0] { + Value::Object(_) => { + format!("first element keys: {}", top_level_keys(&items[0])) + } + other => format!( + "elements are {}s — JSON pointers pluck stored fields, not computed \ + properties like /length", + kind_of(other) + ), + }; + return Err(format!( + "path {path:?} matched nothing in any of the {} elements; {hint}", + items.len() + )); + } + Ok(Value::Array(plucked)) + } + other => Err(format!( + "map needs an array value (got {})", + kind_of(&other) + )), + } +} + +pub fn filter(value: Value, matches: &Map) -> Result { + match value { + Value::Array(items) => Ok(Value::Array( + items + .into_iter() + .filter(|el| matches.iter().all(|(k, want)| el.get(k) == Some(want))) + .collect(), + )), + other => Err(format!( + "filter needs an array value (got {})", + kind_of(&other) + )), + } +} + +pub fn split(value: Value, separator: &str) -> Result { + if separator.is_empty() { + return Err("split needs a non-empty separator".into()); + } + match value { + Value::String(s) => Ok(Value::Array( + s.split(separator) + .map(|p| Value::String(p.to_string())) + .collect(), + )), + other => Err(format!( + "split needs a string value (got {})", + kind_of(&other) + )), + } +} + +pub fn join(value: Value, separator: &str) -> Result { + match value { + Value::Array(items) => Ok(Value::String( + items + .iter() + .map(|el| match el { + Value::String(s) => s.clone(), + other => other.to_string(), + }) + .collect::>() + .join(separator), + )), + other => Err(format!( + "join needs an array value (got {})", + kind_of(&other) + )), + } +} + +// ponytail: serialized-form equality (O(n·size)); fine at pipe scale. +pub fn uniq(value: Value) -> Result { + match value { + Value::Array(items) => { + let mut seen = std::collections::HashSet::new(); + Ok(Value::Array( + items + .into_iter() + .filter(|el| seen.insert(el.to_string())) + .collect(), + )) + } + other => Err(format!( + "uniq needs an array value (got {})", + kind_of(&other) + )), + } +} + +pub fn size(value: &Value) -> Result { + match value { + Value::Array(a) => Ok(json_len(a.len())), + Value::String(s) => Ok(json_len(s.chars().count())), + Value::Object(m) => Ok(json_len(m.len())), + // Deviates from lodash (_.size(5) == 0): a 0 for a non-collection + // would thread garbage into a take/drop downstream. + other => Err(format!( + "size needs an array, string, or object value (got {})", + kind_of(other) + )), + } +} + +// Deviates from lodash: null-only, NOT full falsey — compacting away a +// legitimate 0, false, or "" plucked by map would silently corrupt data. +pub fn compact(value: Value) -> Result { + match value { + Value::Array(items) => Ok(Value::Array( + items.into_iter().filter(|el| !el.is_null()).collect(), + )), + other => Err(format!( + "compact needs an array value (got {})", + kind_of(&other) + )), + } +} + +pub fn nth(value: Value, n: i64) -> Result { + match value { + Value::Array(items) => { + let len = items.len() as i64; + let idx = if n < 0 { len + n } else { n }; + if idx < 0 || idx >= len { + // Deviates from lodash's silent undefined: out of bounds is + // a wrong assumption about the data, so it errors teachably. + return Err(format!( + "nth index {n} is out of bounds for an array of {len} elements" + )); + } + Ok(items + .into_iter() + .nth(idx as usize) + .expect("index bounds checked")) + } + other => Err(format!( + "nth needs an array value (got {})", + kind_of(&other) + )), + } +} + +pub fn get_or(value: &Value, path: &str, default: Value) -> Result { + Ok(value.pointer(path).cloned().unwrap_or(default)) +} + +pub fn flatten(value: Value) -> Result { + match value { + Value::Array(items) => Ok(Value::Array( + items + .into_iter() + .flat_map(|el| match el { + Value::Array(inner) => inner, + other => vec![other], + }) + .collect(), + )), + other => Err(format!( + "flatten needs an array value (got {})", + kind_of(&other) + )), + } +} + +// Deviates from lodash's coercing comparator: keys must all be one +// comparable type — a mixed or missing key means the sort order would be +// garbage, so it errors naming what it found. +pub fn sort_by(value: Value, path: &str) -> Result { + let Value::Array(items) = value else { + return Err(format!( + "sortBy needs an array value (got {})", + kind_of(&value) + )); + }; + let mut keyed: Vec<(Value, Value)> = Vec::with_capacity(items.len()); + for (i, el) in items.into_iter().enumerate() { + let Some(key) = el.pointer(path).cloned() else { + // live-tested (haiku-4.5): "." is the common wrong guess for + // "the element itself" — the JSON pointer for that is "" + let hint = if path == "." { + " (the JSON pointer for the element itself is \"\")" + } else { + "" + }; + return Err(format!( + "path {path:?} matched nothing in element {i}; sorting with holes would \ + scramble the order{hint}" + )); + }; + keyed.push((key, el)); + } + let Some(kind) = keyed.first().map(|(k, _)| kind_of(k)) else { + return Ok(Value::Array(Vec::new())); + }; + if let Some((other, _)) = keyed.iter().find(|(k, _)| kind_of(k) != kind) { + return Err(format!( + "sortBy keys mix {kind} and {} — sort one type at a time", + kind_of(other) + )); + } + match kind { + "number" => keyed.sort_by(|a, b| { + let (x, y) = ( + a.0.as_f64().unwrap_or(f64::NAN), + b.0.as_f64().unwrap_or(f64::NAN), + ); + x.total_cmp(&y) + }), + "string" | "boolean" => keyed.sort_by(|a, b| cmp_scalar(&a.0, &b.0)), + other => { + return Err(format!( + "sortBy keys must be numbers, strings, or booleans (got {other})" + )) + } + } + Ok(Value::Array(keyed.into_iter().map(|(_, el)| el).collect())) +} + +pub fn reverse(value: Value) -> Result { + match value { + Value::Array(mut items) => { + items.reverse(); + Ok(Value::Array(items)) + } + other => Err(format!( + "reverse needs an array value (got {})", + kind_of(&other) + )), + } +} + +fn json_len(n: usize) -> Value { + Value::Number(serde_json::Number::from(n as u64)) +} + +/// Same-type scalar sort keys only (sort_by guarantees homogeneity upstream). +fn cmp_scalar(a: &Value, b: &Value) -> std::cmp::Ordering { + match (a, b) { + (Value::String(x), Value::String(y)) => x.cmp(y), + (Value::Bool(x), Value::Bool(y)) => x.cmp(y), + _ => std::cmp::Ordering::Equal, + } +} + +/// True when `function_id` names one of the ten transforms. Every transform +/// takes its input at `value`, which the pipe uses to fail fast on an +/// unseeded first step. +pub(crate) fn is_transform(function_id: &str) -> bool { + matches!( + function_id, + GET_ID + | PICK_ID + | OMIT_ID + | TAKE_ID + | DROP_ID + | MAP_ID + | FILTER_ID + | SPLIT_ID + | JOIN_ID + | UNIQ_ID + | SIZE_ID + | COMPACT_ID + | NTH_ID + | GET_OR_ID + | FLATTEN_ID + | SORT_BY_ID + | REVERSE_ID + ) +} + +/// Inline dispatch for pipe steps: `None` when `function_id` is not a +/// transform, otherwise the transformed value (or a teachable error). +pub(crate) fn apply(function_id: &str, args: &Value) -> Option> { + fn parse(args: &Value) -> Result { + serde_json::from_value(args.clone()).map_err(|e| format!("invalid arguments: {e}")) + } + match function_id { + GET_ID => Some(parse::(args).and_then(|r| get(&r.value, &r.path))), + PICK_ID => Some(parse::(args).and_then(|r| pick(r.value, &r.paths))), + OMIT_ID => Some(parse::(args).and_then(|r| omit(r.value, &r.paths))), + TAKE_ID => Some(parse::(args).and_then(|r| take(r.value, r.n as usize))), + DROP_ID => Some(parse::(args).and_then(|r| drop(r.value, r.n as usize))), + MAP_ID => Some(parse::(args).and_then(|r| map(r.value, &r.path))), + FILTER_ID => Some(parse::(args).and_then(|r| filter(r.value, &r.matches))), + SPLIT_ID => Some(parse::(args).and_then(|r| split(r.value, &r.separator))), + JOIN_ID => Some(parse::(args).and_then(|r| join(r.value, &r.separator))), + UNIQ_ID => Some(parse::(args).and_then(|r| uniq(r.value))), + SIZE_ID => Some(parse::(args).and_then(|r| size(&r.value))), + COMPACT_ID => Some(parse::(args).and_then(|r| compact(r.value))), + NTH_ID => Some(parse::(args).and_then(|r| nth(r.value, r.n))), + GET_OR_ID => { + Some(parse::(args).and_then(|r| get_or(&r.value, &r.path, r.default))) + } + FLATTEN_ID => Some(parse::(args).and_then(|r| flatten(r.value))), + SORT_BY_ID => Some(parse::(args).and_then(|r| sort_by(r.value, &r.path))), + REVERSE_ID => Some(parse::(args).and_then(|r| reverse(r.value))), + _ => None, + } +} + +pub(crate) fn kind_of(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn top_level_keys(v: &Value) -> String { + match v { + Value::Object(m) => m.keys().cloned().collect::>().join(", "), + other => kind_of(other).to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn get_extracts_by_pointer_with_teachable_misses() { + let v = json!({ "content": "abcdef", "status": 200 }); + assert_eq!(get(&v, "/content").unwrap(), json!("abcdef")); + // root pointer is the identity + assert_eq!(get(&v, "").unwrap(), v); + // a miss names what WAS available + let err = get(&v, "/body").unwrap_err(); + assert!(err.contains("content") && err.contains("status")); + } + + #[test] + fn pick_and_omit_subset_objects_and_refuse_the_rest() { + let v = json!({ "a": 1, "b": 2, "c": 3 }); + assert_eq!( + pick(v.clone(), &["a".into(), "c".into(), "missing".into()]).unwrap(), + json!({ "a": 1, "c": 3 }) + ); + assert_eq!( + omit(v, &["b".into(), "missing".into()]).unwrap(), + json!({ "a": 1, "c": 3 }) + ); + assert!(pick(json!("s"), &["a".into()]) + .unwrap_err() + .contains("object")); + assert!(omit(json!([1]), &["a".into()]) + .unwrap_err() + .contains("object")); + } + + #[test] + fn take_and_drop_slice_strings_and_arrays() { + // char boundaries, not bytes + assert_eq!(take(json!("héllo"), 2).unwrap(), json!("hé")); + assert_eq!(take(json!("hi"), 10).unwrap(), json!("hi")); + assert_eq!(take(json!("hi"), 0).unwrap(), json!("")); + assert_eq!(take(json!([1, 2, 3]), 2).unwrap(), json!([1, 2])); + assert_eq!(take(json!([]), 3).unwrap(), json!([])); + assert!(take(json!({ "a": 1 }), 2).unwrap_err().contains("string")); + + assert_eq!(drop(json!("héllo"), 2).unwrap(), json!("llo")); + assert_eq!(drop(json!("hi"), 10).unwrap(), json!("")); + assert_eq!(drop(json!([1, 2, 3]), 2).unwrap(), json!([3])); + assert!(drop(json!(5), 1).unwrap_err().contains("string")); + } + + #[test] + fn map_plucks_with_null_misses_but_rejects_a_total_miss() { + let v = json!([{ "id": 1 }, { "id": 2 }, { "other": 3 }]); + assert_eq!(map(v, "/id").unwrap(), json!([1, 2, null])); + assert!(map(json!("s"), "/id").unwrap_err().contains("array")); + + // a stored null is a hit, not a miss + assert_eq!(map(json!([{ "id": null }]), "/id").unwrap(), json!([null])); + // empty input has no wrong path to detect + assert_eq!(map(json!([]), "/id").unwrap(), json!([])); + + // a path matching NOTHING errors instead of threading all-nulls — + // naming the keys that exist... + let err = map(json!([{ "id": 1 }, { "id": 2 }]), "/uuid").unwrap_err(); + assert!(err.contains("matched nothing") && err.contains("id")); + // ...and teaching the computed-property trap on primitives + // (the live session tried lodash's `/length` on strings) + let err = map(json!(["apple", "banana"]), "/length").unwrap_err(); + assert!(err.contains("string") && err.contains("computed")); + } + + #[test] + fn is_transform_matches_exactly_the_seventeen_ops() { + for id in [ + GET_ID, PICK_ID, OMIT_ID, TAKE_ID, DROP_ID, MAP_ID, FILTER_ID, SPLIT_ID, JOIN_ID, + UNIQ_ID, SIZE_ID, COMPACT_ID, NTH_ID, GET_OR_ID, FLATTEN_ID, SORT_BY_ID, REVERSE_ID, + ] { + assert!(is_transform(id)); + } + assert!(!is_transform("fp::pipe")); + assert!(!is_transform("state::set")); + } + + #[test] + fn size_counts_collections_and_refuses_the_rest() { + assert_eq!(size(&json!([1, 2, 3])).unwrap(), json!(3)); + // chars, not bytes + assert_eq!(size(&json!("héllo")).unwrap(), json!(5)); + assert_eq!(size(&json!({"a": 1, "b": 2})).unwrap(), json!(2)); + assert_eq!(size(&json!([])).unwrap(), json!(0)); + // lodash returns 0 here; a silent 0 threads garbage, so we error + assert!(size(&json!(5)).unwrap_err().contains("array")); + assert!(size(&json!(null)).unwrap_err().contains("array")); + } + + #[test] + fn compact_removes_null_only() { + // NOT lodash falsey: 0, false and "" are data and survive + assert_eq!( + compact(json!([0, null, false, "", 1, null, "x"])).unwrap(), + json!([0, false, "", 1, "x"]) + ); + assert!(compact(json!("s")).unwrap_err().contains("array")); + } + + #[test] + fn nth_indexes_both_ends_and_errors_out_of_bounds() { + let v = json!(["a", "b", "c"]); + assert_eq!(nth(v.clone(), 0).unwrap(), json!("a")); + assert_eq!(nth(v.clone(), 2).unwrap(), json!("c")); + // the hole JSON Pointer cannot express: the last element + assert_eq!(nth(v.clone(), -1).unwrap(), json!("c")); + assert_eq!(nth(v.clone(), -3).unwrap(), json!("a")); + let err = nth(v.clone(), 3).unwrap_err(); + assert!(err.contains("3 elements")); + assert!(nth(v, -4).unwrap_err().contains("out of bounds")); + assert!(nth(json!({}), 0).unwrap_err().contains("array")); + } + + #[test] + fn get_or_defaults_only_on_a_true_miss() { + let v = json!({ "a": 1, "b": null }); + assert_eq!(get_or(&v, "/a", json!("dflt")).unwrap(), json!(1)); + assert_eq!( + get_or(&v, "/missing", json!("dflt")).unwrap(), + json!("dflt") + ); + // a stored null is a hit, mirroring map's rule + assert_eq!(get_or(&v, "/b", json!("dflt")).unwrap(), json!(null)); + } + + #[test] + fn flatten_unnests_one_level() { + assert_eq!( + flatten(json!([1, [2, 3], [[4]]])).unwrap(), + json!([1, 2, 3, [4]]) + ); + assert_eq!(flatten(json!([])).unwrap(), json!([])); + assert!(flatten(json!(1)).unwrap_err().contains("array")); + } + + #[test] + fn sort_by_is_stable_typed_and_loud() { + let v = json!([ + { "s": 3, "id": "c" }, + { "s": 1, "id": "a" }, + { "s": 3, "id": "b" }, + ]); + // ascending, stable: the two s=3 keep their relative order + assert_eq!( + sort_by(v, "/s").unwrap(), + json!([ + { "s": 1, "id": "a" }, + { "s": 3, "id": "c" }, + { "s": 3, "id": "b" }, + ]) + ); + assert_eq!( + sort_by(json!([{ "n": "b" }, { "n": "a" }]), "/n").unwrap(), + json!([{ "n": "a" }, { "n": "b" }]) + ); + assert_eq!(sort_by(json!([]), "/x").unwrap(), json!([])); + + // path "" sorts primitives by themselves; "." gets the redirect hint + assert_eq!(sort_by(json!([3, 1, 2]), "").unwrap(), json!([1, 2, 3])); + assert!(sort_by(json!([2, 1]), ".") + .unwrap_err() + .contains("element itself")); + + // holes and mixed types would scramble the order — loud, not coerced + let err = sort_by(json!([{ "s": 1 }, {}]), "/s").unwrap_err(); + assert!(err.contains("element 1")); + let err = sort_by(json!([{ "s": 1 }, { "s": "x" }]), "/s").unwrap_err(); + assert!(err.contains("number") && err.contains("string")); + let err = sort_by(json!([{ "s": {} }]), "/s").unwrap_err(); + assert!(err.contains("numbers, strings, or booleans")); + assert!(sort_by(json!("s"), "/x").unwrap_err().contains("array")); + } + + #[test] + fn reverse_flips_arrays_immutably() { + assert_eq!(reverse(json!([1, 2, 3])).unwrap(), json!([3, 2, 1])); + assert_eq!(reverse(json!([])).unwrap(), json!([])); + assert!(reverse(json!("abc")).unwrap_err().contains("array")); + } + + #[test] + fn filter_keeps_matching_elements() { + let v = json!([ + { "status": "active", "id": 1 }, + { "status": "gone", "id": 2 }, + "not-an-object", + { "status": "active", "id": 3 }, + ]); + let matches = json!({ "status": "active" }); + let Value::Object(matches) = matches else { + unreachable!() + }; + assert_eq!( + filter(v.clone(), &matches).unwrap(), + json!([{ "status": "active", "id": 1 }, { "status": "active", "id": 3 }]) + ); + // empty matches keeps everything (vacuous truth, lodash-compatible) + assert_eq!(filter(v.clone(), &Map::new()).unwrap(), v); + assert!(filter(json!({}), &Map::new()) + .unwrap_err() + .contains("array")); + } + + #[test] + fn split_and_join_round_trip_strings() { + assert_eq!( + split(json!("a\nb\nc"), "\n").unwrap(), + json!(["a", "b", "c"]) + ); + assert!(split(json!("x"), "").unwrap_err().contains("non-empty")); + assert!(split(json!(1), ",").unwrap_err().contains("string")); + + assert_eq!(join(json!(["a", "b"]), "\n").unwrap(), json!("a\nb")); + // non-strings serialize + assert_eq!(join(json!([1, "x", null]), ",").unwrap(), json!("1,x,null")); + assert!(join(json!("s"), ",").unwrap_err().contains("array")); + } + + #[test] + fn uniq_keeps_first_occurrences() { + assert_eq!( + uniq(json!([1, 2, 1, { "a": 1 }, { "a": 1 }, "1"])).unwrap(), + json!([1, 2, { "a": 1 }, "1"]) + ); + assert!(uniq(json!("s")).unwrap_err().contains("array")); + } + + #[test] + fn apply_dispatches_transform_ids_only() { + assert!(apply("state::set", &json!({})).is_none()); + assert!(apply("fp::pipe", &json!({})).is_none()); + assert_eq!( + apply(GET_ID, &json!({ "value": { "a": 1 }, "path": "/a" })), + Some(Ok(json!(1))) + ); + assert_eq!( + apply(TAKE_ID, &json!({ "value": "héllo", "n": 2 })), + Some(Ok(json!("hé"))) + ); + // join's separator defaults like lodash + assert_eq!( + apply(JOIN_ID, &json!({ "value": ["a", "b"] })), + Some(Ok(json!("a,b"))) + ); + assert_eq!( + apply( + FILTER_ID, + &json!({ "value": [{"s": 1}, {"s": 2}], "matches": {"s": 2} }) + ), + Some(Ok(json!([{"s": 2}]))) + ); + // bad shape is a step error, not a panic + assert!(apply(TAKE_ID, &json!({ "value": "x" })) + .unwrap() + .unwrap_err() + .contains("invalid arguments")); + } +} diff --git a/fp/tests/contract.rs b/fp/tests/contract.rs new file mode 100644 index 000000000..76bb93737 --- /dev/null +++ b/fp/tests/contract.rs @@ -0,0 +1,85 @@ +//! Public-contract tests. CI's validate_worker.py requires a non-empty +//! tests/ suite for source-changed workers; the deep edge-case coverage +//! lives in the unit tests under src/. + +use fp::{pipe, util}; +use serde_json::json; + +#[test] +fn transforms_cover_the_lodash_surface() { + assert_eq!( + util::get(&json!({"a": {"b": 1}}), "/a/b").unwrap(), + json!(1) + ); + assert_eq!( + util::pick(json!({"a": 1, "b": 2}), &["a".into()]).unwrap(), + json!({"a": 1}) + ); + assert_eq!( + util::omit(json!({"a": 1, "b": 2}), &["a".into()]).unwrap(), + json!({"b": 2}) + ); + assert_eq!(util::take(json!("héllo"), 2).unwrap(), json!("hé")); + assert_eq!(util::drop(json!([1, 2, 3]), 2).unwrap(), json!([3])); + assert_eq!( + util::map(json!([{"id": 1}, {"id": 2}]), "/id").unwrap(), + json!([1, 2]) + ); + let matches: serde_json::Map = + serde_json::from_value(json!({"s": "x"})).unwrap(); + assert_eq!( + util::filter(json!([{"s": "x"}, {"s": "y"}]), &matches).unwrap(), + json!([{"s": "x"}]) + ); + assert_eq!(util::split(json!("a,b"), ",").unwrap(), json!(["a", "b"])); + assert_eq!(util::join(json!(["a", "b"]), "-").unwrap(), json!("a-b")); + assert_eq!(util::uniq(json!([1, 1, 2])).unwrap(), json!([1, 2])); + assert_eq!(util::size(&json!("héllo")).unwrap(), json!(5)); + assert_eq!(util::compact(json!([0, null, ""])).unwrap(), json!([0, ""])); + assert_eq!(util::nth(json!(["a", "b"]), -1).unwrap(), json!("b")); + assert_eq!( + util::get_or(&json!({}), "/x", json!("d")).unwrap(), + json!("d") + ); + assert_eq!(util::flatten(json!([1, [2]])).unwrap(), json!([1, 2])); + assert_eq!( + util::sort_by(json!([{"s": 2}, {"s": 1}]), "/s").unwrap(), + json!([{"s": 1}, {"s": 2}]) + ); + assert_eq!(util::reverse(json!([1, 2])).unwrap(), json!([2, 1])); + + // misses and type mismatches are teachable errors, never silent garbage + assert!(util::get(&json!({"a": 1}), "/b").unwrap_err().contains("a")); + assert!(util::pick(json!([1]), &["a".into()]) + .unwrap_err() + .contains("object")); +} + +#[test] +fn pipe_contract_validates_and_refuses() { + // The canonical live shape, engine-injected _caller_worker_id included. + let req: pipe::PipeRequest = serde_json::from_value(json!({ + "through": [ + { "function": "scrapling::fetch", "payload": { "url": "u", "format": "markdown" } }, + { "function": "fp::get", "payload": { "path": "/content" } }, + { "function": "fp::take", "payload": { "n": 6000 } }, + { "function": "state::set", "payload": { "scope": "s", "key": "k" } }, + ], + "_caller_worker_id": "harness", + })) + .expect("live shape parses"); + assert!(pipe::validate(&req).is_ok()); + + // Worker-authority-sensitive classes are refused as steps. + for forbidden in [ + "configuration::get", + "harness::send", + "router::chat", + "session::append", + "fp::pipe", + ] { + let req: pipe::PipeRequest = + serde_json::from_value(json!({ "through": [{ "function": forbidden }] })).unwrap(); + assert!(pipe::validate(&req).is_err(), "{forbidden}"); + } +} diff --git a/harness/prompts/cli.txt b/harness/prompts/cli.txt index b8afd2e2e..1b865e91b 100644 --- a/harness/prompts/cli.txt +++ b/harness/prompts/cli.txt @@ -187,14 +187,29 @@ to `harness::react` and put the sub-agent you want in the trigger's `metadata`: "function_id": "harness::react", "config": { "session_id": "" }, "metadata": { "model": "", "task": "", - "session_id": "", + "session_id": "", "parent_session_id": "" } }' -`metadata.parent_session_id` pins where the reacting sub-agent nests in the console tree; -omitted, the reaction nests under the registering session's root automatically (session -events: the firing session's root). If you pin one it MUST be a real session id — an invented -group id has no session behind it, so the children render as disconnected top-level rows. `metadata.model` MUST be a live id from `router::models::list` — never a model name from memory (an unknown model is rejected at registration and never spawns). A trigger-fired sub-agent starts with only a read-only baseline (discovery, reads, subscriptions — no writes, no spawning) — grant anything more via `metadata.options` (same shape as `harness::spawn` `options`), e.g. `"options": { "functions": { "allow": ["state::get", "shell::fs::*"] } }`. +`metadata.session_id` picks WHICH session the reacting sub-agent runs in — omitting it does +NOT create a fresh distinct child. It falls back to the session that REGISTERED this trigger, +so the reaction fires back into THAT chat every time (fine for a pipeline's last stage, +deliberately delivering the final result "back here" — wrong for any EARLIER stage meant to +run as its own child). Any stage you want spawned as a distinct sub-agent — including each +branch of a fan-out like "two parallel analysts" — needs its OWN explicit `session_id`, picked +by you, unique to this run, same discipline as a direct `harness::spawn` call (a reused id +silently RESUMES that old session instead of starting fresh). + +`metadata.parent_session_id` pins where the reacting sub-agent nests in the console tree +(unrelated to which session it runs in); omitted, the reaction nests under the registering +session's root automatically (session events: the firing session's root). If you pin one it +MUST be a real session id — an invented group id has no session behind it, so the children +render as disconnected top-level rows. `metadata.model` MUST be a live id from +`router::models::list` — never a model name from memory (an unknown model is rejected at +registration and never spawns). A trigger-fired sub-agent starts with only a read-only baseline +(discovery, reads, subscriptions — no writes, no spawning) — grant anything more via +`metadata.options` (same shape as `harness::spawn` `options`), e.g. +`"options": { "functions": { "allow": ["state::get", "shell::fs::*"] } }`. `harness::react` is documented here on purpose: it never runs as a direct call (agents are denied), only as a trigger target — do not look it up or probe it first; use the id exactly as diff --git a/harness/prompts/default.txt b/harness/prompts/default.txt index 6a57d3d24..ac7b05334 100644 --- a/harness/prompts/default.txt +++ b/harness/prompts/default.txt @@ -138,9 +138,10 @@ Name every child you spawn: always pass `session_id` — a short readable slug f job plus a few random characters for uniqueness, e.g. `fetch-headlines-b4k9`. Never prefix it with your own session id. Omitted, the engine mints an opaque UUID row in the console; a slug without the random suffix can collide with an earlier run and silently resume that session, -old transcript and all. Direct `harness::spawn` calls only — in a react trigger's `metadata` -(below), leave `session_id` out unless re-aiming delivery: a fixed id there funnels every -firing into one session. +old transcript and all. A react trigger's `metadata.session_id` has its own rule (Reacting +section below): pin a fresh unique id for each stage that must run as its own child; omit it +only when the reaction should deliver back into the registering chat — and never pin a fixed +id on a recurring trigger, which funnels every firing into one session. Children run fail-closed: a spawned child starts from a narrowed read-mostly baseline (discovery, reads, subscriptions), NOT from your policy. Whatever its task requires it to @@ -160,15 +161,33 @@ to `harness::react` and put the sub-agent you want in the trigger's `metadata`: config: { parent_session_id: "" }, # the type's config schema (filters) metadata: { task: "", model: "", - session_id: "", + session_id: "", parent_session_id: "" } } -`metadata.parent_session_id` pins where the reacting sub-agent nests in the console tree. It -MUST be a REAL session id — normally your own. An invented group id has no session behind it, -so the console cannot attach the children anywhere and shows them as disconnected top-level -rows. Omit it and the reaction nests under the firing session's root (session events) or the -registering session's root (`state`/`cron`/`stream` events carry no session in the event). `metadata.model` is OPTIONAL — omit it and the reaction runs on your own model. Set it only to switch models, and then only to a live id from `router::models::list`, never one from memory (an unknown model is rejected at registration and never spawns). A trigger-fired sub-agent starts with only a read-only baseline (discovery, reads, subscriptions — no writes, no spawning) — grant anything more via `metadata.options` (same shape as `harness::spawn` `options`), e.g. `options: { functions: { allow: ["state::get", "shell::fs::*"] } }`. +`metadata.session_id` picks WHICH session the reacting sub-agent runs in — omitting it does +NOT create a fresh distinct child. It falls back to the session that REGISTERED this trigger, +so the reaction fires back into THAT chat every time (fine for a pipeline's last stage, +deliberately delivering the final result "back here" — wrong for any EARLIER stage meant to +run as its own child). Any stage you want spawned as a distinct sub-agent — including each +branch of a fan-out like "two parallel analysts" — needs its OWN explicit `session_id`, picked +by you, unique to this run (same discipline as a direct `harness::spawn` call and as the Join +section's predecessor ids below): a readable slug plus a few random characters, e.g. +`summarizer-b4k9`. +Reusing an id from an earlier run silently RESUMES that old session instead of starting fresh. + +`metadata.parent_session_id` pins where the reacting sub-agent nests in the console tree +(unrelated to which session it runs in). It MUST be a REAL session id — normally your own. An +invented group id has no session behind it, so the console cannot attach the children anywhere +and shows them as disconnected top-level rows. Omit it and the reaction nests under the firing +session's root (session events) or the registering session's root (`state`/`cron`/`stream` +events carry no session in the event). `metadata.model` is OPTIONAL — omit it and the reaction +runs on your own model; set it only to switch models, and then only to a live id from +`router::models::list`, never one from memory (an unknown model is rejected at registration and +never spawns). A trigger-fired sub-agent starts with only a read-only baseline (discovery, reads, +subscriptions — no writes, no spawning) — grant anything more via `metadata.options` (same shape +as `harness::spawn` `options`), e.g. +`options: { functions: { allow: ["state::get", "shell::fs::*"] } }`. `harness::react` is documented here on purpose: it never runs as a direct call (agents are denied), only as a trigger target — do not look it up or probe it first; use the id exactly as diff --git a/iii-permissions.yaml b/iii-permissions.yaml index fb3e96f94..0560d886d 100644 --- a/iii-permissions.yaml +++ b/iii-permissions.yaml @@ -196,6 +196,28 @@ rules: - coder::search - coder::list-folder - coder::tree + # fp: the seventeen pure value transforms are side-effect-free reshapes (no + # I/O, no authority). fp::pipe stays at the needs_approval default ON + # PURPOSE: its steps run with the fp worker's authority, so the pipe + # call itself is the surface an approver reviews (the worker statically + # refuses hard-denied classes as steps; see fp/src/pipe.rs). + - fp::get + - fp::pick + - fp::omit + - fp::take + - fp::drop + - fp::map + - fp::filter + - fp::split + - fp::join + - fp::uniq + - fp::size + - fp::compact + - fp::nth + - fp::getOr + - fp::flatten + - fp::sortBy + - fp::reverse # web::fetch is load-bearing for the system prompt's SDK-reference gate and # HTTP-trigger verification; it enforces size/timeout caps and server-side # SSRF protection, so it is allowed by default. diff --git a/provider-anthropic/prompts/identity.txt b/provider-anthropic/prompts/identity.txt index 012e20c0a..03063b96e 100644 --- a/provider-anthropic/prompts/identity.txt +++ b/provider-anthropic/prompts/identity.txt @@ -71,11 +71,12 @@ Does THIS reply need the child's answer? - YES → `harness::spawn` directly (parks the turn until the child resolves). Independent spawns in ONE message run concurrently; across messages they serialize. - NO ("when X do Y": follow-up stages, watchers, notifications, pipelines) → register the reaction FIRST via `engine::register_trigger`, THEN kick off stage one via `harness::spawn` (that one park is fine). When the park resumes, acknowledge and end — the reactions own the follow-up; never redo their work. NEVER chain parked spawns to sequence work this reply doesn't need. -ALWAYS pass `session_id` on direct spawns: short slug + a few random chars (e.g. `fetch-headlines-b4k9`); never prefix with your own session id. Omitted → opaque UUID; without the random suffix it can collide with an earlier run and silently resume that session, old transcript and all. In react `metadata`, leave `session_id` OUT unless re-aiming delivery — a fixed id funnels every firing into one session. +ALWAYS pass `session_id` on direct spawns: short slug + a few random chars (e.g. `fetch-headlines-b4k9`); never prefix with your own session id. Omitted → opaque UUID; without the random suffix it can collide with an earlier run and silently resume that session, old transcript and all. In react `metadata`, `session_id` works the OPPOSITE way: omitting it does NOT mint a fresh child, it falls back to the registering session, so the reaction fires back into THAT chat every time — fine for a pipeline's last stage delivering its result back here on purpose, wrong for any earlier stage (including each branch of a fan-out) meant to run as its own child, which needs its OWN explicit `session_id` instead. Children run fail-closed: direct spawns and trigger-fired sub-agents alike start from a narrowed read-mostly baseline (discovery, reads, subscriptions), NOT from your policy — whatever the task requires the child to CALL, grant explicitly via `options: { functions: { allow: [...] } }`. A child told to write state without `state::set` in its allow list finishes politely with its work stranded in its transcript, and every reaction armed on that write waits forever. Events can't bind straight to `harness::spawn` (a `harness::turn-completed`/`state` event carries no `task`/`model`); bind `harness::react`: `engine::register_trigger { trigger_type, function_id: "harness::react", config: , metadata: { task, model?, session_id?, parent_session_id? } }`. `harness::react` is documented HERE on purpose: never call it directly or probe it via discovery (agents denied; trigger target only); keep the returned id to unregister. +- `metadata.session_id` picks WHICH session the reacting sub-agent runs in — omitting it does NOT spawn a fresh distinct child, it falls back to the session that REGISTERED the trigger, so the reaction fires back into THAT chat every time. Fine for a pipeline's last stage (deliver the result "back here" on purpose); wrong for any earlier stage meant to run as its own child — every such stage, including each branch of a fan-out ("two parallel analysts"), needs its OWN explicit `session_id` you pick, unique to this run, same discipline as a direct `harness::spawn` call (a reused id silently RESUMES that old session). - Simple non-cron react bindings default to one-shot. Set `once: false` only for a deliberate standing watcher; cron is recurring by default. On join predecessors `once` is ignored — the join owns their lifecycle (auto-unregister when it fires; `join.rearm: true` keeps them). The response echoes the EFFECTIVE `once`; trust the echo, not what you sent. - `metadata.model` is OPTIONAL — omit it and the reaction runs on your own model. Set it only to switch models, and then only to a live id from `router::models::list`, never one from memory; unknown models are rejected at registration and never spawn. - `metadata.parent_session_id` pins console nesting; omitted → nests under your root (registering session, or the firing session's root for session events); if pinned, MUST be a REAL session id — invented ids render children as disconnected top-level rows. diff --git a/provider-llamacpp/prompts/identity.txt b/provider-llamacpp/prompts/identity.txt index 012e20c0a..03063b96e 100644 --- a/provider-llamacpp/prompts/identity.txt +++ b/provider-llamacpp/prompts/identity.txt @@ -71,11 +71,12 @@ Does THIS reply need the child's answer? - YES → `harness::spawn` directly (parks the turn until the child resolves). Independent spawns in ONE message run concurrently; across messages they serialize. - NO ("when X do Y": follow-up stages, watchers, notifications, pipelines) → register the reaction FIRST via `engine::register_trigger`, THEN kick off stage one via `harness::spawn` (that one park is fine). When the park resumes, acknowledge and end — the reactions own the follow-up; never redo their work. NEVER chain parked spawns to sequence work this reply doesn't need. -ALWAYS pass `session_id` on direct spawns: short slug + a few random chars (e.g. `fetch-headlines-b4k9`); never prefix with your own session id. Omitted → opaque UUID; without the random suffix it can collide with an earlier run and silently resume that session, old transcript and all. In react `metadata`, leave `session_id` OUT unless re-aiming delivery — a fixed id funnels every firing into one session. +ALWAYS pass `session_id` on direct spawns: short slug + a few random chars (e.g. `fetch-headlines-b4k9`); never prefix with your own session id. Omitted → opaque UUID; without the random suffix it can collide with an earlier run and silently resume that session, old transcript and all. In react `metadata`, `session_id` works the OPPOSITE way: omitting it does NOT mint a fresh child, it falls back to the registering session, so the reaction fires back into THAT chat every time — fine for a pipeline's last stage delivering its result back here on purpose, wrong for any earlier stage (including each branch of a fan-out) meant to run as its own child, which needs its OWN explicit `session_id` instead. Children run fail-closed: direct spawns and trigger-fired sub-agents alike start from a narrowed read-mostly baseline (discovery, reads, subscriptions), NOT from your policy — whatever the task requires the child to CALL, grant explicitly via `options: { functions: { allow: [...] } }`. A child told to write state without `state::set` in its allow list finishes politely with its work stranded in its transcript, and every reaction armed on that write waits forever. Events can't bind straight to `harness::spawn` (a `harness::turn-completed`/`state` event carries no `task`/`model`); bind `harness::react`: `engine::register_trigger { trigger_type, function_id: "harness::react", config: , metadata: { task, model?, session_id?, parent_session_id? } }`. `harness::react` is documented HERE on purpose: never call it directly or probe it via discovery (agents denied; trigger target only); keep the returned id to unregister. +- `metadata.session_id` picks WHICH session the reacting sub-agent runs in — omitting it does NOT spawn a fresh distinct child, it falls back to the session that REGISTERED the trigger, so the reaction fires back into THAT chat every time. Fine for a pipeline's last stage (deliver the result "back here" on purpose); wrong for any earlier stage meant to run as its own child — every such stage, including each branch of a fan-out ("two parallel analysts"), needs its OWN explicit `session_id` you pick, unique to this run, same discipline as a direct `harness::spawn` call (a reused id silently RESUMES that old session). - Simple non-cron react bindings default to one-shot. Set `once: false` only for a deliberate standing watcher; cron is recurring by default. On join predecessors `once` is ignored — the join owns their lifecycle (auto-unregister when it fires; `join.rearm: true` keeps them). The response echoes the EFFECTIVE `once`; trust the echo, not what you sent. - `metadata.model` is OPTIONAL — omit it and the reaction runs on your own model. Set it only to switch models, and then only to a live id from `router::models::list`, never one from memory; unknown models are rejected at registration and never spawn. - `metadata.parent_session_id` pins console nesting; omitted → nests under your root (registering session, or the firing session's root for session events); if pinned, MUST be a REAL session id — invented ids render children as disconnected top-level rows. diff --git a/provider-openai-codex/prompts/identity.txt b/provider-openai-codex/prompts/identity.txt index 33b3343f9..ea2abae06 100644 --- a/provider-openai-codex/prompts/identity.txt +++ b/provider-openai-codex/prompts/identity.txt @@ -198,9 +198,14 @@ reaction's work). Name every child you spawn: always pass `session_id` — a sho slug for the child's job plus a few random characters for uniqueness, e.g. `fetch-headlines-b4k9`; never prefix it with your own session id (omitted, the engine mints an opaque UUID row in the console; a slug without the random suffix can collide with an -earlier run and silently resume that session; direct `harness::spawn` calls only — in a react -trigger's `metadata` below, leave `session_id` out unless re-aiming delivery, since a fixed -id there funnels every firing into one session). +earlier run and silently resume that session — direct `harness::spawn` calls only. In a +react trigger's `metadata` below, `session_id` works the OPPOSITE way: omitting it does NOT +mint a fresh child, it falls back to the session that REGISTERED the trigger, so the +reaction fires back into THAT chat every time — fine for a pipeline's last stage delivering +its result "back here" on purpose, wrong for any earlier stage meant to run as its own +child. Every such stage — including each branch of a fan-out like "two parallel +analysts" — needs its OWN explicit `session_id` you pick, unique to this run, same as a +direct spawn). Children run fail-closed: direct spawns and trigger-fired sub-agents alike start from a narrowed read-mostly baseline (discovery, reads, subscriptions), NOT from your policy — whatever the task requires the child to CALL, grant explicitly via diff --git a/provider-openai/prompts/identity.txt b/provider-openai/prompts/identity.txt index f9c0aa025..5253ae8aa 100644 --- a/provider-openai/prompts/identity.txt +++ b/provider-openai/prompts/identity.txt @@ -193,9 +193,14 @@ reaction's work). Name every child you spawn: always pass `session_id` — a sho slug for the child's job plus a few random characters for uniqueness, e.g. `fetch-headlines-b4k9`; never prefix it with your own session id (omitted, the engine mints an opaque UUID row in the console; a slug without the random suffix can collide with an -earlier run and silently resume that session; direct `harness::spawn` calls only — in a react -trigger's `metadata` below, leave `session_id` out unless re-aiming delivery, since a fixed -id there funnels every firing into one session). +earlier run and silently resume that session — direct `harness::spawn` calls only. In a +react trigger's `metadata` below, `session_id` works the OPPOSITE way: omitting it does NOT +mint a fresh child, it falls back to the session that REGISTERED the trigger, so the +reaction fires back into THAT chat every time — fine for a pipeline's last stage delivering +its result "back here" on purpose, wrong for any earlier stage meant to run as its own +child. Every such stage — including each branch of a fan-out like "two parallel +analysts" — needs its OWN explicit `session_id` you pick, unique to this run, same as a +direct spawn). Children run fail-closed: direct spawns and trigger-fired sub-agents alike start from a narrowed read-mostly baseline (discovery, reads, subscriptions), NOT from your policy — whatever the task requires the child to CALL, grant explicitly via diff --git a/provider-xai/prompts/identity.txt b/provider-xai/prompts/identity.txt index f9c0aa025..5253ae8aa 100644 --- a/provider-xai/prompts/identity.txt +++ b/provider-xai/prompts/identity.txt @@ -193,9 +193,14 @@ reaction's work). Name every child you spawn: always pass `session_id` — a sho slug for the child's job plus a few random characters for uniqueness, e.g. `fetch-headlines-b4k9`; never prefix it with your own session id (omitted, the engine mints an opaque UUID row in the console; a slug without the random suffix can collide with an -earlier run and silently resume that session; direct `harness::spawn` calls only — in a react -trigger's `metadata` below, leave `session_id` out unless re-aiming delivery, since a fixed -id there funnels every firing into one session). +earlier run and silently resume that session — direct `harness::spawn` calls only. In a +react trigger's `metadata` below, `session_id` works the OPPOSITE way: omitting it does NOT +mint a fresh child, it falls back to the session that REGISTERED the trigger, so the +reaction fires back into THAT chat every time — fine for a pipeline's last stage delivering +its result "back here" on purpose, wrong for any earlier stage meant to run as its own +child. Every such stage — including each branch of a fan-out like "two parallel +analysts" — needs its OWN explicit `session_id` you pick, unique to this run, same as a +direct spawn). Children run fail-closed: direct spawns and trigger-fired sub-agents alike start from a narrowed read-mostly baseline (discovery, reads, subscriptions), NOT from your policy — whatever the task requires the child to CALL, grant explicitly via diff --git a/provider-zai/prompts/identity.txt b/provider-zai/prompts/identity.txt index 012e20c0a..03063b96e 100644 --- a/provider-zai/prompts/identity.txt +++ b/provider-zai/prompts/identity.txt @@ -71,11 +71,12 @@ Does THIS reply need the child's answer? - YES → `harness::spawn` directly (parks the turn until the child resolves). Independent spawns in ONE message run concurrently; across messages they serialize. - NO ("when X do Y": follow-up stages, watchers, notifications, pipelines) → register the reaction FIRST via `engine::register_trigger`, THEN kick off stage one via `harness::spawn` (that one park is fine). When the park resumes, acknowledge and end — the reactions own the follow-up; never redo their work. NEVER chain parked spawns to sequence work this reply doesn't need. -ALWAYS pass `session_id` on direct spawns: short slug + a few random chars (e.g. `fetch-headlines-b4k9`); never prefix with your own session id. Omitted → opaque UUID; without the random suffix it can collide with an earlier run and silently resume that session, old transcript and all. In react `metadata`, leave `session_id` OUT unless re-aiming delivery — a fixed id funnels every firing into one session. +ALWAYS pass `session_id` on direct spawns: short slug + a few random chars (e.g. `fetch-headlines-b4k9`); never prefix with your own session id. Omitted → opaque UUID; without the random suffix it can collide with an earlier run and silently resume that session, old transcript and all. In react `metadata`, `session_id` works the OPPOSITE way: omitting it does NOT mint a fresh child, it falls back to the registering session, so the reaction fires back into THAT chat every time — fine for a pipeline's last stage delivering its result back here on purpose, wrong for any earlier stage (including each branch of a fan-out) meant to run as its own child, which needs its OWN explicit `session_id` instead. Children run fail-closed: direct spawns and trigger-fired sub-agents alike start from a narrowed read-mostly baseline (discovery, reads, subscriptions), NOT from your policy — whatever the task requires the child to CALL, grant explicitly via `options: { functions: { allow: [...] } }`. A child told to write state without `state::set` in its allow list finishes politely with its work stranded in its transcript, and every reaction armed on that write waits forever. Events can't bind straight to `harness::spawn` (a `harness::turn-completed`/`state` event carries no `task`/`model`); bind `harness::react`: `engine::register_trigger { trigger_type, function_id: "harness::react", config: , metadata: { task, model?, session_id?, parent_session_id? } }`. `harness::react` is documented HERE on purpose: never call it directly or probe it via discovery (agents denied; trigger target only); keep the returned id to unregister. +- `metadata.session_id` picks WHICH session the reacting sub-agent runs in — omitting it does NOT spawn a fresh distinct child, it falls back to the session that REGISTERED the trigger, so the reaction fires back into THAT chat every time. Fine for a pipeline's last stage (deliver the result "back here" on purpose); wrong for any earlier stage meant to run as its own child — every such stage, including each branch of a fan-out ("two parallel analysts"), needs its OWN explicit `session_id` you pick, unique to this run, same discipline as a direct `harness::spawn` call (a reused id silently RESUMES that old session). - Simple non-cron react bindings default to one-shot. Set `once: false` only for a deliberate standing watcher; cron is recurring by default. On join predecessors `once` is ignored — the join owns their lifecycle (auto-unregister when it fires; `join.rearm: true` keeps them). The response echoes the EFFECTIVE `once`; trust the echo, not what you sent. - `metadata.model` is OPTIONAL — omit it and the reaction runs on your own model. Set it only to switch models, and then only to a live id from `router::models::list`, never one from memory; unknown models are rejected at registration and never spawn. - `metadata.parent_session_id` pins console nesting; omitted → nests under your root (registering session, or the firing session's root for session events); if pinned, MUST be a REAL session id — invented ids render children as disconnected top-level rows. From b959d3ba2bdea02dd00eea30a7f5b7b94574488e Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Thu, 16 Jul 2026 20:01:10 -0300 Subject: [PATCH 2/2] (MOT-3948) fix(ci): bump boot-smoke engine pin to 0.21.6 iii-hq/iii pruned old releases; the 0.17.0 tag no longer resolves, which failed every worker's interface-boot-smoke job identically. 0.21.6 is the current stable release and matches the workers' iii-sdk = "=0.21.6" pin. --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b733225a..4c2779a43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,7 +256,10 @@ jobs: - name: Install iii CLI + engine if: steps.optout.outputs.skip != 'true' env: - VERSION: '0.17.0' + # Must be a tag that still exists on iii-hq/iii (old releases get + # pruned — 0.17.0 vanished and broke every boot smoke). Keep aligned + # with the workers' iii-sdk pin. + VERSION: '0.21.6' GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail