@try-works/dsh-model-as-tool 0.1.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dsh-tool-mode-credentials.mjs +20 -0
- package/bin/dsh-tool-mode.mjs +202 -0
- package/catalog/desert-ant-models.json +693 -693
- package/client/client.model-tools.js +614 -0
- package/client/index.js +1717 -0
- package/lib/adapters/index.d.ts.map +1 -1
- package/lib/adapters/index.js +4 -4
- package/lib/bindings.d.ts.map +1 -1
- package/lib/bindings.js +26 -25
- package/lib/capability.d.ts.map +1 -1
- package/lib/capability.js +17 -0
- package/lib/capture-path.d.ts +97 -0
- package/lib/capture-path.d.ts.map +1 -0
- package/lib/capture-path.js +152 -0
- package/lib/diagnostics.d.ts +166 -0
- package/lib/diagnostics.d.ts.map +1 -0
- package/lib/diagnostics.js +135 -0
- package/lib/extension-dependencies.d.ts +325 -0
- package/lib/extension-dependencies.d.ts.map +1 -0
- package/lib/extension-dependencies.js +858 -0
- package/lib/extension-dispatch.d.ts +117 -0
- package/lib/extension-dispatch.d.ts.map +1 -0
- package/lib/extension-dispatch.js +124 -0
- package/lib/extension-registrar.d.ts +100 -0
- package/lib/extension-registrar.d.ts.map +1 -0
- package/lib/extension-registrar.js +134 -0
- package/lib/extensions-api.d.ts +221 -0
- package/lib/extensions-api.d.ts.map +1 -0
- package/lib/extensions-api.js +17 -0
- package/lib/extensions-remote.d.ts +54 -0
- package/lib/extensions-remote.d.ts.map +1 -0
- package/lib/extensions-remote.js +66 -0
- package/lib/extensions-service.d.ts +367 -0
- package/lib/extensions-service.d.ts.map +1 -0
- package/lib/extensions-service.js +1527 -0
- package/lib/harness-llm.d.ts +138 -0
- package/lib/harness-llm.d.ts.map +1 -0
- package/lib/harness-llm.js +218 -0
- package/lib/harness-serving.d.ts +95 -0
- package/lib/harness-serving.d.ts.map +1 -0
- package/lib/harness-serving.js +157 -0
- package/lib/headless.d.ts +97 -0
- package/lib/headless.d.ts.map +1 -0
- package/lib/headless.js +349 -0
- package/lib/http-acquisition.d.ts +27 -0
- package/lib/http-acquisition.d.ts.map +1 -0
- package/lib/http-acquisition.js +136 -0
- package/lib/index.d.ts +271 -7
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2644 -70
- package/lib/invocation-accounting.d.ts +169 -0
- package/lib/invocation-accounting.d.ts.map +1 -0
- package/lib/invocation-accounting.js +148 -0
- package/lib/legacy-migration.d.ts +95 -0
- package/lib/legacy-migration.d.ts.map +1 -0
- package/lib/legacy-migration.js +429 -0
- package/lib/managed-workers.d.ts +151 -0
- package/lib/managed-workers.d.ts.map +1 -0
- package/lib/managed-workers.js +249 -0
- package/lib/mcp/client-grant.d.ts +74 -0
- package/lib/mcp/client-grant.d.ts.map +1 -0
- package/lib/mcp/client-grant.js +60 -0
- package/lib/mcp/credential-cli.d.ts +38 -0
- package/lib/mcp/credential-cli.d.ts.map +1 -0
- package/lib/mcp/credential-cli.js +148 -0
- package/lib/mcp/credential-store.d.ts +143 -0
- package/lib/mcp/credential-store.d.ts.map +1 -0
- package/lib/mcp/credential-store.js +454 -0
- package/lib/mcp/host-binding.d.ts +25 -0
- package/lib/mcp/host-binding.d.ts.map +1 -1
- package/lib/mcp/host-binding.js +107 -14
- package/lib/mcp/http.d.ts +68 -18
- package/lib/mcp/http.d.ts.map +1 -1
- package/lib/mcp/http.js +390 -62
- package/lib/mcp/oauth.d.ts +149 -0
- package/lib/mcp/oauth.d.ts.map +1 -0
- package/lib/mcp/oauth.js +396 -0
- package/lib/mcp/protocol.d.ts +46 -7
- package/lib/mcp/protocol.d.ts.map +1 -1
- package/lib/mcp/protocol.js +63 -16
- package/lib/mcp/rate-limit.d.ts +68 -0
- package/lib/mcp/rate-limit.d.ts.map +1 -0
- package/lib/mcp/rate-limit.js +114 -0
- package/lib/mcp/sdk-v2.d.ts +17 -0
- package/lib/mcp/sdk-v2.d.ts.map +1 -0
- package/lib/mcp/sdk-v2.js +181 -0
- package/lib/mcp/server.d.ts +11 -1
- package/lib/mcp/server.d.ts.map +1 -1
- package/lib/mcp/server.js +80 -13
- package/lib/mcp/stdio.d.ts.map +1 -1
- package/lib/mcp/stdio.js +9 -1
- package/lib/mcp/subscriptions.d.ts +43 -1
- package/lib/mcp/subscriptions.d.ts.map +1 -1
- package/lib/mcp/subscriptions.js +90 -9
- package/lib/model-qualification.d.ts +192 -0
- package/lib/model-qualification.d.ts.map +1 -0
- package/lib/model-qualification.js +178 -0
- package/lib/planning.d.ts +246 -0
- package/lib/planning.d.ts.map +1 -0
- package/lib/planning.js +457 -0
- package/lib/prepared-assets.d.ts +178 -0
- package/lib/prepared-assets.d.ts.map +1 -0
- package/lib/prepared-assets.js +286 -0
- package/lib/provider-bridge.d.ts +37 -0
- package/lib/provider-bridge.d.ts.map +1 -1
- package/lib/provider-bridge.js +189 -12
- package/lib/ptc-authority.d.ts +140 -0
- package/lib/ptc-authority.d.ts.map +1 -0
- package/lib/ptc-authority.js +216 -0
- package/lib/ptc-runtime.d.ts +156 -0
- package/lib/ptc-runtime.d.ts.map +1 -0
- package/lib/ptc-runtime.js +191 -0
- package/lib/publication.d.ts +2 -1
- package/lib/publication.d.ts.map +1 -1
- package/lib/publication.js +13 -5
- package/lib/receipt.d.ts.map +1 -1
- package/lib/receipt.js +8 -0
- package/lib/title-proposal.d.ts +194 -0
- package/lib/title-proposal.d.ts.map +1 -0
- package/lib/title-proposal.js +244 -0
- package/lib/tool-mode-run.d.ts +235 -0
- package/lib/tool-mode-run.d.ts.map +1 -0
- package/lib/tool-mode-run.js +283 -0
- package/lib/types.d.ts +3 -1
- package/lib/types.d.ts.map +1 -1
- package/lib/update-surface.d.ts +79 -0
- package/lib/update-surface.d.ts.map +1 -0
- package/lib/update-surface.js +224 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/standard.d.ts +38 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/standard.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/standard.js +445 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-embed.d.ts +14 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-embed.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-embed.js +63 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-rerank.d.ts +14 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-rerank.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-rerank.js +76 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/dsh.d.ts.map +1 -1
- package/node_modules/@try-works/tool-mode-contracts/lib/dsh.js +69 -55
- package/node_modules/@try-works/tool-mode-contracts/lib/index.d.ts +4 -1
- package/node_modules/@try-works/tool-mode-contracts/lib/index.d.ts.map +1 -1
- package/node_modules/@try-works/tool-mode-contracts/lib/index.js +26 -2
- package/node_modules/@try-works/tool-mode-contracts/lib/registry.d.ts +28 -2
- package/node_modules/@try-works/tool-mode-contracts/lib/registry.d.ts.map +1 -1
- package/node_modules/@try-works/tool-mode-contracts/lib/registry.js +93 -10
- package/node_modules/@try-works/tool-mode-contracts/package.json +0 -1
- package/node_modules/@try-works/tool-mode-extension-sdk/bin/dsh-tool-mode.mjs +20 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/cli.d.ts +80 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/cli.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/cli.js +533 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/declarative-recipes.d.ts +83 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/declarative-recipes.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/declarative-recipes.js +125 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/discovery.d.ts +70 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/discovery.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/discovery.js +201 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/errors.d.ts +57 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/errors.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/errors.js +83 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/index.d.ts +32 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/index.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/index.js +32 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/archive.d.ts +86 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/archive.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/archive.js +191 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/dependencies.d.ts +126 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/dependencies.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/dependencies.js +297 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/lifecycle.d.ts +216 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/lifecycle.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/lifecycle.js +800 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/migration.d.ts +210 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/migration.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/migration.js +368 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/runtime-link.d.ts +63 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/runtime-link.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/runtime-link.js +149 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/source-directory.d.ts +35 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/source-directory.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/source-directory.js +179 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/staged.d.ts +179 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/staged.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/staged.js +235 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/store.d.ts +367 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/store.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/store.js +807 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/tar-gzip-source.d.ts +44 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/tar-gzip-source.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/tar-gzip-source.js +243 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/update-flow.d.ts +185 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/update-flow.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/update-flow.js +504 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/zip-source.d.ts +17 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/zip-source.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/zip-source.js +80 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/load.d.ts +52 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/load.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/load.js +102 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/manifest.d.ts +171 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/manifest.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/manifest.js +271 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/runtime.d.ts +242 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/runtime.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/runtime.js +58 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/state.d.ts +118 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/state.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/state.js +113 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker/worker-child.d.ts +2 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker/worker-child.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker/worker-child.js +351 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker.d.ts +150 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker.js +670 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/package.json +53 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/schema/extension-manifest-1.1.0.schema.json +253 -0
- package/node_modules/@try-works/tool-mode-recipes/bin/tool-mode-recipe.mjs +15 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/broker.d.ts +427 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/broker.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/broker.js +592 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/cli.d.ts +60 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/cli.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/cli.js +936 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/dsh-adapter.d.ts +237 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/dsh-adapter.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/dsh-adapter.js +200 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/fixture-provider.d.ts +85 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/fixture-provider.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/fixture-provider.js +176 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/images.d.ts +105 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/images.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/images.js +129 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/index.d.ts +25 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/index.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/index.js +25 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/recipe-format.d.ts +325 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/recipe-format.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/recipe-format.js +1310 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/templates.d.ts +51 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/templates.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/templates.js +310 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/titles.d.ts +227 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/titles.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/titles.js +407 -0
- package/node_modules/@try-works/tool-mode-recipes/package.json +56 -0
- package/node_modules/@try-works/tool-mode-recipes/recipes/describe-image.json +38 -0
- package/node_modules/@try-works/tool-mode-recipes/recipes/text-title.json +45 -0
- package/node_modules/@try-works/tool-mode-recipes/schema/harness-model-recipe.schema.json +273 -0
- package/package.json +43 -15
- package/remote/spec.mjs +462 -0
- package/remote/typert.host.mjs +14 -0
- package/remote/typert.remote-client.mjs +16 -0
- package/lib/adapters/tongue.d.ts +0 -31
- package/lib/adapters/tongue.d.ts.map +0 -1
- package/lib/adapters/tongue.js +0 -93
- package/lib/adapters/voz.d.ts +0 -28
- package/lib/adapters/voz.d.ts.map +0 -1
- package/lib/adapters/voz.js +0 -102
- /package/{lib → node_modules/@try-works/tool-mode-extension-sdk/lib}/licensing.d.ts +0 -0
- /package/{lib → node_modules/@try-works/tool-mode-extension-sdk/lib}/licensing.d.ts.map +0 -0
- /package/{lib → node_modules/@try-works/tool-mode-extension-sdk/lib}/licensing.js +0 -0
|
@@ -0,0 +1,1310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declarative recipe package format (RCP-01, RCP-02, RCP-03, RCP-05, RCP-06).
|
|
3
|
+
*
|
|
4
|
+
* A recipe is TRUSTED, DATA-ONLY: a static instruction string plus typed input
|
|
5
|
+
* bindings. It is deliberately neither a PTC program nor an autonomous subagent. It has
|
|
6
|
+
* no statements, no expressions, no imports and no filesystem access, because the moment
|
|
7
|
+
* a recipe can COMPUTE something it needs an interpreter with a threat model instead of
|
|
8
|
+
* a validator with an allow-list — and the threat model is the expensive part.
|
|
9
|
+
*
|
|
10
|
+
* Every rule below exists to keep that true against a recipe that is trying not to be
|
|
11
|
+
* data, and each one names what it prevents:
|
|
12
|
+
*
|
|
13
|
+
* - **Unknown keys are refused, not ignored.** A key this host does not implement is
|
|
14
|
+
* either a typo or a field that means something elsewhere; silently dropping it runs a
|
|
15
|
+
* recipe that is not the one the author wrote (RCP-02, RCP-06).
|
|
16
|
+
* - **`tools`, `messages`, `history` and `conversation` are refused BY NAME**, with a
|
|
17
|
+
* reason that says a recipe is data and not a program. HLM-05 forbids inheriting the
|
|
18
|
+
* parent's conversation, system prompt, tools, skills, attachments or memory, so a
|
|
19
|
+
* recipe that asks for any of them is refused here rather than quietly starved of them
|
|
20
|
+
* at dispatch time.
|
|
21
|
+
* - **Executable template syntax is refused.** `{{name}}` is a binding placeholder and
|
|
22
|
+
* nothing else; `${...}` and `<%...%>` are template CODE, and RCP-02 forbids it.
|
|
23
|
+
* - **A placeholder that names no declared input is refused** (RCP-03: an unbound
|
|
24
|
+
* pointer), because a template referring to something the caller can never supply is a
|
|
25
|
+
* recipe that cannot be satisfied.
|
|
26
|
+
* - **The limits are RES-10's initial per-leaf maxima**, asserted individually: 1024
|
|
27
|
+
* output tokens and two image inputs. A recipe may lower a limit, never raise it.
|
|
28
|
+
* - **The parsed recipe is deep-frozen**, so a caller holding it cannot widen the limits
|
|
29
|
+
* or rewrite the instructions it was authorized with (RCP-05, RCP-06).
|
|
30
|
+
*
|
|
31
|
+
* Bindings are a NAME-KEYED map rather than the specification's JSON Pointers on
|
|
32
|
+
* purpose. A pointer is a small language — `/a/b/0`, `~0` escapes, `-`, traversal past a
|
|
33
|
+
* reserved member — and RCP-03 has to reject all of it. A closed map of identifier-named
|
|
34
|
+
* inputs has no pointer to traverse, so the whole class of pointer abuse is absent by
|
|
35
|
+
* construction rather than by check.
|
|
36
|
+
*
|
|
37
|
+
* @module @try-works/tool-mode-recipes/recipe-format
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* RES-10's initial maximum output tokens for one leaf invocation.
|
|
41
|
+
*
|
|
42
|
+
* Restated here rather than imported: this package must not depend on the host package,
|
|
43
|
+
* because a declarative recipe package is loaded by the host and not the other way
|
|
44
|
+
* around (EXT-02, ARCH-02). The number is asserted in the tests so a drift between the
|
|
45
|
+
* two copies is a failing test rather than a silent budget change.
|
|
46
|
+
*/
|
|
47
|
+
export const INITIAL_MAX_OUTPUT_TOKENS = 1024;
|
|
48
|
+
/** RES-10's initial maximum image inputs for one leaf invocation. */
|
|
49
|
+
export const INITIAL_MAX_IMAGE_INPUTS = 2;
|
|
50
|
+
/** The only keys a recipe may carry at the top level. */
|
|
51
|
+
export const RECIPE_TOP_LEVEL_KEYS = [
|
|
52
|
+
'id',
|
|
53
|
+
'version',
|
|
54
|
+
'description',
|
|
55
|
+
'tool',
|
|
56
|
+
'inputs',
|
|
57
|
+
'promptTemplate',
|
|
58
|
+
'maxOutputTokens',
|
|
59
|
+
'imageInputs',
|
|
60
|
+
'policy',
|
|
61
|
+
];
|
|
62
|
+
/**
|
|
63
|
+
* Key names that are refused wherever they appear, at any depth.
|
|
64
|
+
*
|
|
65
|
+
* These are the fields through which a recipe would stop being data and start being a
|
|
66
|
+
* program with access to the parent agent's context. They are checked BEFORE the
|
|
67
|
+
* allow-list so the failure names the real problem — "a recipe is data, not a program" —
|
|
68
|
+
* instead of reporting a generic unknown key.
|
|
69
|
+
*/
|
|
70
|
+
export const FORBIDDEN_RECIPE_KEYS = [
|
|
71
|
+
'tools',
|
|
72
|
+
'tool_calls',
|
|
73
|
+
'toolCalls',
|
|
74
|
+
'messages',
|
|
75
|
+
'history',
|
|
76
|
+
'conversation',
|
|
77
|
+
'attachments',
|
|
78
|
+
'memory',
|
|
79
|
+
'skills',
|
|
80
|
+
'systemPrompt',
|
|
81
|
+
// RCP-02's own context flags. HLM-05 says a flag meaning "take the entire
|
|
82
|
+
// conversation" is not supported in this release, so the flag is refused rather than
|
|
83
|
+
// accepted and then ignored — an ignored flag is a promise the host does not keep.
|
|
84
|
+
'inheritConversation',
|
|
85
|
+
'includeTools',
|
|
86
|
+
];
|
|
87
|
+
/**
|
|
88
|
+
* Property names that must never appear, because they reach past data into the object
|
|
89
|
+
* model (RCP-03: reserved prototype/member abuse).
|
|
90
|
+
*/
|
|
91
|
+
export const RESERVED_RECIPE_KEYS = ['__proto__', 'constructor', 'prototype'];
|
|
92
|
+
/** A legal input name, a legal placeholder and a legal JSON Schema property. */
|
|
93
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
94
|
+
/**
|
|
95
|
+
* A legal contract identity.
|
|
96
|
+
*
|
|
97
|
+
* Two spellings, because the specification uses two: a STANDARD contract is a dotted
|
|
98
|
+
* lower-case identity (`text.title`), which is the form `tool-mode-contracts` publishes,
|
|
99
|
+
* and a THIRD-PARTY contract is scoped to the package that owns it
|
|
100
|
+
* (`@acme/tool-mode-image-review/review`), which is the form the TMX manifest example
|
|
101
|
+
* uses. Refusing the second would reject a spec-conformant third-party recipe, so both
|
|
102
|
+
* are admitted and the identity comparison below is what actually protects against a
|
|
103
|
+
* recipe claiming another tool.
|
|
104
|
+
*/
|
|
105
|
+
const CONTRACT_IDENTITY = /^(?:[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+|@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)+)$/;
|
|
106
|
+
/** The keys a recipe's `tool` object may carry. */
|
|
107
|
+
const TOOL_KEYS = ['name', 'contractId'];
|
|
108
|
+
/** The keys a recipe's `imageInputs` object may carry. */
|
|
109
|
+
const IMAGE_INPUTS_KEYS = ['max'];
|
|
110
|
+
/** The keys a recipe's `policy` object may carry. */
|
|
111
|
+
const POLICY_KEYS = ['record', 'allowRemote'];
|
|
112
|
+
/** The keys one input declaration may carry. */
|
|
113
|
+
const INPUT_KEYS = [
|
|
114
|
+
'type',
|
|
115
|
+
'required',
|
|
116
|
+
'description',
|
|
117
|
+
'minLength',
|
|
118
|
+
'maxLength',
|
|
119
|
+
'minimum',
|
|
120
|
+
'maximum',
|
|
121
|
+
'enum',
|
|
122
|
+
'default',
|
|
123
|
+
'mediaTypes',
|
|
124
|
+
'maxBytes',
|
|
125
|
+
'jsonSerialized',
|
|
126
|
+
];
|
|
127
|
+
/** The kinds this format understands, for validation and error messages. */
|
|
128
|
+
const INPUT_KINDS = ['string', 'integer', 'number', 'boolean', 'image'];
|
|
129
|
+
/**
|
|
130
|
+
* Render one named error.
|
|
131
|
+
*
|
|
132
|
+
* The code prefix is what a caller switches on; the sentence after it is what a human
|
|
133
|
+
* reads. Both are needed: a code alone does not say which key was wrong, and a sentence
|
|
134
|
+
* alone cannot be matched on without matching prose.
|
|
135
|
+
* @param code - the error code.
|
|
136
|
+
* @param message - the specific problem, naming the offending key or field.
|
|
137
|
+
* @returns the rendered error.
|
|
138
|
+
*/
|
|
139
|
+
export function formatRecipeError(code, message) {
|
|
140
|
+
return `${code}: ${message}`;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The code of a rendered error.
|
|
144
|
+
* @param error - a rendered error.
|
|
145
|
+
* @returns the code, or the whole string when it carries no prefix.
|
|
146
|
+
*/
|
|
147
|
+
export function errorCodeOf(error) {
|
|
148
|
+
const separator = error.indexOf(':');
|
|
149
|
+
return separator === -1 ? error : error.slice(0, separator);
|
|
150
|
+
}
|
|
151
|
+
/** Whether a value is a plain JSON-style object, as opposed to an array or a class. */
|
|
152
|
+
function isPlainObject(value) {
|
|
153
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
154
|
+
return false;
|
|
155
|
+
const prototype = Object.getPrototypeOf(value);
|
|
156
|
+
// A class instance, a Date, a Map or a RegExp is not data; a null-prototype object is.
|
|
157
|
+
return prototype === Object.prototype || prototype === null;
|
|
158
|
+
}
|
|
159
|
+
/** Name a value's kind for an error message. */
|
|
160
|
+
function describeKind(value) {
|
|
161
|
+
if (value === null)
|
|
162
|
+
return 'null';
|
|
163
|
+
if (Array.isArray(value))
|
|
164
|
+
return 'an array';
|
|
165
|
+
const kind = typeof value;
|
|
166
|
+
if (kind !== 'object')
|
|
167
|
+
return `a ${kind}`;
|
|
168
|
+
return isPlainObject(value) ? 'an object' : 'a non-plain object (a class instance is not data)';
|
|
169
|
+
}
|
|
170
|
+
/** Freeze a recipe in place, at every depth. */
|
|
171
|
+
function deepFreeze(value) {
|
|
172
|
+
if (typeof value === 'object' && value !== null) {
|
|
173
|
+
for (const child of Object.values(value))
|
|
174
|
+
deepFreeze(child);
|
|
175
|
+
Object.freeze(value);
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Walk the raw recipe, refusing anything that is not data.
|
|
181
|
+
*
|
|
182
|
+
* Two rules run together here because both are about a value's POSITION, not its shape:
|
|
183
|
+
* a key that names context the request must not carry, and a key that reaches into the
|
|
184
|
+
* object model. Doing this as a walk rather than as a top-level check is what catches
|
|
185
|
+
* `{ tool: { history: [] } }`, which a top-level allow-list would report as a mere
|
|
186
|
+
* unknown nested key.
|
|
187
|
+
* @param node - the value being walked.
|
|
188
|
+
* @param path - the dotted path, for the error message.
|
|
189
|
+
* @param errors - collected errors.
|
|
190
|
+
*/
|
|
191
|
+
function checkDataOnly(node, path, errors) {
|
|
192
|
+
if (node === null || typeof node === 'string' || typeof node === 'boolean')
|
|
193
|
+
return;
|
|
194
|
+
if (typeof node === 'number') {
|
|
195
|
+
if (!Number.isFinite(node)) {
|
|
196
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path} is not a finite number; NaN and Infinity are not JSON data`));
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (Array.isArray(node)) {
|
|
201
|
+
for (const [index, item] of node.entries())
|
|
202
|
+
checkDataOnly(item, `${path}[${index}]`, errors);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!isPlainObject(node)) {
|
|
206
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path} is ${describeKind(node)}, which is not data. A recipe may contain only JSON values — no functions, `
|
|
207
|
+
+ 'no classes, no executable template code (RCP-02)'));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
for (const [key, value] of Object.entries(node)) {
|
|
211
|
+
const child = path === '' ? key : `${path}.${key}`;
|
|
212
|
+
if (FORBIDDEN_RECIPE_KEYS.includes(key)) {
|
|
213
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${child} is refused by name. A recipe is DATA, not a program: it cannot supply tools, messages, `
|
|
214
|
+
+ 'conversation history, attachments, memory or skills, because the broker builds the provider request from '
|
|
215
|
+
+ 'the recipe and the bound inputs alone (HLM-05, HLM-06). Remove the key; something that needs tools is a '
|
|
216
|
+
+ 'PTC program, which is a different artifact'));
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (RESERVED_RECIPE_KEYS.includes(key)) {
|
|
220
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${child} uses a reserved property name; RCP-03 rejects reserved prototype/member abuse`));
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
checkDataOnly(value, child, errors);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** The unknown keys in an object, against an allow-list. */
|
|
227
|
+
function unknownKeys(value, allowed) {
|
|
228
|
+
return Object.keys(value).filter(key => !allowed.includes(key));
|
|
229
|
+
}
|
|
230
|
+
/** Render an unknown-key refusal. */
|
|
231
|
+
function unknownKeyError(path, key, allowed) {
|
|
232
|
+
return formatRecipeError('INVALID_RECIPE', `${path}${path === '' ? '' : '.'}${key} is not a key this format has. Allowed here: ${allowed.join(', ')}. `
|
|
233
|
+
+ 'A recipe\'s shape is closed: an unknown key is a typo or a field this host does not implement, and ignoring it '
|
|
234
|
+
+ 'would run a recipe other than the one the author wrote (RCP-02, RCP-06)');
|
|
235
|
+
}
|
|
236
|
+
/** Read a finite integer, or report why not. */
|
|
237
|
+
function readInteger(value, path, errors) {
|
|
238
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
239
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path} must be an integer; received ${describeKind(value)}`));
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
/** Read a non-empty string, or report why not. */
|
|
245
|
+
function readNonEmptyString(value, path, errors) {
|
|
246
|
+
if (typeof value !== 'string') {
|
|
247
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path} must be a string; received ${describeKind(value)}`));
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
if (value.trim().length === 0) {
|
|
251
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path} must not be empty`));
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
return value;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Validate one input declaration.
|
|
258
|
+
* @param name - the input name.
|
|
259
|
+
* @param raw - the raw declaration.
|
|
260
|
+
* @param errors - collected errors.
|
|
261
|
+
* @returns the declaration, when it is valid enough to keep.
|
|
262
|
+
*/
|
|
263
|
+
function parseInputDeclaration(name, raw, errors) {
|
|
264
|
+
const path = `inputs.${name}`;
|
|
265
|
+
if (!IDENTIFIER.test(name)) {
|
|
266
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path} is not a legal input name. A name is an identifier because it is also the placeholder the template `
|
|
267
|
+
+ 'binds, and a name that cannot be written as `{{name}}` cannot be bound at all'));
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
if (!isPlainObject(raw)) {
|
|
271
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path} must be an object; received ${describeKind(raw)}`));
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
for (const key of unknownKeys(raw, INPUT_KEYS))
|
|
275
|
+
errors.push(unknownKeyError(path, key, INPUT_KEYS));
|
|
276
|
+
const declaredType = raw['type'];
|
|
277
|
+
if (typeof declaredType !== 'string' || !INPUT_KINDS.includes(declaredType)) {
|
|
278
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path}.type must be one of ${INPUT_KINDS.join(', ')}; received ${JSON.stringify(declaredType)}`));
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
const type = declaredType;
|
|
282
|
+
const requiredRaw = raw['required'];
|
|
283
|
+
if (requiredRaw !== undefined && typeof requiredRaw !== 'boolean') {
|
|
284
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path}.required must be a boolean`));
|
|
285
|
+
}
|
|
286
|
+
const required = requiredRaw === true;
|
|
287
|
+
const declaration = { type, required };
|
|
288
|
+
// A canonical `inputBindings` kind "json" binding arrives as a string
|
|
289
|
+
// declaration carrying this marker; it is read through so the canonical
|
|
290
|
+
// round trip stays lossless.
|
|
291
|
+
if (raw['jsonSerialized'] !== undefined) {
|
|
292
|
+
if (raw['jsonSerialized'] !== true || type !== 'string') {
|
|
293
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path}.jsonSerialized must be true and only applies to a string binding`));
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
declaration.jsonSerialized = true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (raw['description'] !== undefined) {
|
|
300
|
+
const description = readNonEmptyString(raw['description'], `${path}.description`, errors);
|
|
301
|
+
if (description !== undefined)
|
|
302
|
+
declaration.description = description;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Keywords are only accepted on the kind they constrain.
|
|
306
|
+
*
|
|
307
|
+
* A `maxLength` on an image, or a `minimum` on a string, is a constraint its author
|
|
308
|
+
* believes is enforced. Silently ignoring it would ship a bound the recipe never had,
|
|
309
|
+
* which is the failure BASE-04 names for schema projection.
|
|
310
|
+
*/
|
|
311
|
+
const keyword = (key, kinds) => {
|
|
312
|
+
const value = raw[key];
|
|
313
|
+
if (value === undefined)
|
|
314
|
+
return undefined;
|
|
315
|
+
if (!kinds.includes(type)) {
|
|
316
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path}.${key} does not apply to an input of type "${type}"; it constrains ${kinds.join(' or ')}. A constraint `
|
|
317
|
+
+ 'that is ignored is a bound the author believes is enforced'));
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
return value;
|
|
321
|
+
};
|
|
322
|
+
const minLength = keyword('minLength', ['string']);
|
|
323
|
+
if (minLength !== undefined) {
|
|
324
|
+
const value = readInteger(minLength, `${path}.minLength`, errors);
|
|
325
|
+
if (value !== undefined)
|
|
326
|
+
declaration.minLength = value;
|
|
327
|
+
}
|
|
328
|
+
const maxLength = keyword('maxLength', ['string']);
|
|
329
|
+
if (maxLength !== undefined) {
|
|
330
|
+
const value = readInteger(maxLength, `${path}.maxLength`, errors);
|
|
331
|
+
if (value !== undefined)
|
|
332
|
+
declaration.maxLength = value;
|
|
333
|
+
}
|
|
334
|
+
const minimum = keyword('minimum', ['integer', 'number']);
|
|
335
|
+
if (minimum !== undefined) {
|
|
336
|
+
const value = typeof minimum === 'number' && Number.isFinite(minimum) ? minimum : undefined;
|
|
337
|
+
if (value === undefined)
|
|
338
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path}.minimum must be a finite number`));
|
|
339
|
+
else
|
|
340
|
+
declaration.minimum = value;
|
|
341
|
+
}
|
|
342
|
+
const maximum = keyword('maximum', ['integer', 'number']);
|
|
343
|
+
if (maximum !== undefined) {
|
|
344
|
+
const value = typeof maximum === 'number' && Number.isFinite(maximum) ? maximum : undefined;
|
|
345
|
+
if (value === undefined)
|
|
346
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path}.maximum must be a finite number`));
|
|
347
|
+
else
|
|
348
|
+
declaration.maximum = value;
|
|
349
|
+
}
|
|
350
|
+
const enumerated = keyword('enum', ['string', 'integer', 'number', 'boolean']);
|
|
351
|
+
if (enumerated !== undefined) {
|
|
352
|
+
if (!Array.isArray(enumerated) || enumerated.length === 0
|
|
353
|
+
|| !enumerated.every(member => typeof member === 'string' || typeof member === 'number' || typeof member === 'boolean')) {
|
|
354
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path}.enum must be a non-empty array of scalar values`));
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
declaration.enum = enumerated;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const mediaTypes = keyword('mediaTypes', ['image']);
|
|
361
|
+
if (mediaTypes !== undefined) {
|
|
362
|
+
if (!Array.isArray(mediaTypes) || mediaTypes.length === 0
|
|
363
|
+
|| !mediaTypes.every(member => typeof member === 'string' && member.startsWith('image/'))) {
|
|
364
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path}.mediaTypes must be a non-empty array of image media types (for example "image/png")`));
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
declaration.mediaTypes = mediaTypes;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const maxBytes = keyword('maxBytes', ['image']);
|
|
371
|
+
if (maxBytes !== undefined) {
|
|
372
|
+
const value = readInteger(maxBytes, `${path}.maxBytes`, errors);
|
|
373
|
+
if (value !== undefined) {
|
|
374
|
+
if (value < 1)
|
|
375
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path}.maxBytes must be at least 1`));
|
|
376
|
+
else
|
|
377
|
+
declaration.maxBytes = value;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (raw['default'] !== undefined) {
|
|
381
|
+
const fallback = raw['default'];
|
|
382
|
+
const scalar = typeof fallback === 'string' || typeof fallback === 'number' || typeof fallback === 'boolean';
|
|
383
|
+
if (!scalar || type === 'image') {
|
|
384
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path}.default must be a scalar matching the declared type; an image binding has no default, because a `
|
|
385
|
+
+ 'default image would be a media reference the caller never supplied'));
|
|
386
|
+
}
|
|
387
|
+
else if (!matchesScalarType(type, fallback)) {
|
|
388
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path}.default does not match the declared type ${type}`));
|
|
389
|
+
}
|
|
390
|
+
else if (declaration.enum !== undefined && !declaration.enum.includes(fallback)) {
|
|
391
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path}.default is not one of the declared enum values`));
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
declaration.default = fallback;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return declaration;
|
|
398
|
+
}
|
|
399
|
+
/** Whether a scalar satisfies a declared scalar kind. */
|
|
400
|
+
function matchesScalarType(type, value) {
|
|
401
|
+
switch (type) {
|
|
402
|
+
case 'string': return typeof value === 'string';
|
|
403
|
+
case 'boolean': return typeof value === 'boolean';
|
|
404
|
+
case 'integer': return typeof value === 'number' && Number.isInteger(value);
|
|
405
|
+
case 'number': return typeof value === 'number' && Number.isFinite(value);
|
|
406
|
+
case 'image': return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** Every `{{name}}` placeholder in a template, in order of appearance. */
|
|
410
|
+
function placeholdersIn(template) {
|
|
411
|
+
const found = [];
|
|
412
|
+
for (const match of template.matchAll(/\{\{([^{}]*)\}\}/g)) {
|
|
413
|
+
found.push((match[1] ?? '').trim());
|
|
414
|
+
}
|
|
415
|
+
return found;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Normalize a CANONICAL RCP-02 document into the legacy raw shape the parser
|
|
419
|
+
* below reads, collecting every canonical-key violation as a named error.
|
|
420
|
+
*
|
|
421
|
+
* The mapping is: `instructions`→`promptTemplate`; `inputBindings`→`inputs`
|
|
422
|
+
* (pointer→name, kind text|image|json, required, label→description);
|
|
423
|
+
* `limits.maxOutputTokens`→`maxOutputTokens`; `limits.maxImages`→
|
|
424
|
+
* `imageInputs.max`; `policy` defaults to `{record:true, allowRemote:true}`
|
|
425
|
+
* (recording defaults ON per HLM-09). `modelSelection.strategy:"caller-model"`
|
|
426
|
+
* is the default and is validated then dropped; `"approved-profile"` is kept.
|
|
427
|
+
* `context` must be `{inheritConversation:false, includeTools:false}` — the
|
|
428
|
+
* "take the entire conversation" flag is not supported in this release
|
|
429
|
+
* (HLM-05) and is refused. `response` is validated and kept. `schemaVersion`
|
|
430
|
+
* and `kind:"harness-model"` are constants. `limits.timeoutMs`,
|
|
431
|
+
* `maxInputTextBytes`, `maxAttempts`, `repairAttempts` are validated and
|
|
432
|
+
* dropped: the broker enforces the RES-10 ceilings and the caller owns the
|
|
433
|
+
* wall-clock deadline (RES-10's 60 s figure is the caller's to pass).
|
|
434
|
+
*
|
|
435
|
+
* @param raw - the decoded recipe document.
|
|
436
|
+
* @param errors - the error sink.
|
|
437
|
+
* @returns the legacy-shaped document plus the canonical-only extras, or
|
|
438
|
+
* @param raw - the decoded recipe document.
|
|
439
|
+
* @param errors - the error sink.
|
|
440
|
+
* @returns the legacy-shaped document plus the canonical-only extras.
|
|
441
|
+
*/
|
|
442
|
+
function normalizeRecipeDocument(raw, errors) {
|
|
443
|
+
const normalized = {};
|
|
444
|
+
const extras = {};
|
|
445
|
+
// Every canonical sub-object is CLOSED: unknown keys are refused, and the
|
|
446
|
+
// forbidden/reserved names are refused by name — nothing is silently dropped.
|
|
447
|
+
const refuseUnsafeKeys = (container, path, allowed) => {
|
|
448
|
+
for (const key of Object.keys(container)) {
|
|
449
|
+
if (allowed.includes(key))
|
|
450
|
+
continue;
|
|
451
|
+
if (FORBIDDEN_RECIPE_KEYS.includes(key)) {
|
|
452
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path}.${key} is refused by name. A recipe is DATA, not a program: it cannot supply tools, messages, `
|
|
453
|
+
+ 'conversation history, attachments, memory or skills (HLM-05, HLM-06)'));
|
|
454
|
+
}
|
|
455
|
+
else if (RESERVED_RECIPE_KEYS.includes(key)) {
|
|
456
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${path}.${key} is a reserved prototype member and is refused by name (RCP-03)`));
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
errors.push(unknownKeyError(path, key, allowed));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
// Constants: schemaVersion is accepted and pinned; kind must be harness-model.
|
|
464
|
+
if (raw['schemaVersion'] !== undefined) {
|
|
465
|
+
if (typeof raw['schemaVersion'] !== 'string' || raw['schemaVersion'].length === 0) {
|
|
466
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'schemaVersion must be a version string (for example "1.0.0")'));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (raw['kind'] !== undefined && raw['kind'] !== 'harness-model') {
|
|
470
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `kind must be "harness-model" when declared; received ${describeKind(raw['kind'])}`));
|
|
471
|
+
}
|
|
472
|
+
for (const key of ['id', 'version', 'description', 'tool']) {
|
|
473
|
+
if (raw[key] !== undefined)
|
|
474
|
+
normalized[key] = raw[key];
|
|
475
|
+
}
|
|
476
|
+
// --- instructions -----------------------------------------------------------
|
|
477
|
+
const instructions = raw['instructions'];
|
|
478
|
+
if (typeof instructions !== 'string' || instructions.length === 0) {
|
|
479
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `instructions must be a non-empty string; received ${describeKind(instructions)}`));
|
|
480
|
+
}
|
|
481
|
+
// --- modelSelection (RCP-05) ------------------------------------------------
|
|
482
|
+
const modelSelection = raw['modelSelection'];
|
|
483
|
+
const requires = { inputModalities: [], outputModalities: [] };
|
|
484
|
+
let strategy;
|
|
485
|
+
if (!isPlainObject(modelSelection)) {
|
|
486
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `modelSelection must be an object carrying "strategy" and "requires"; received ${describeKind(modelSelection)}`));
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
refuseUnsafeKeys(modelSelection, 'modelSelection', ['strategy', 'requires', 'profile']);
|
|
490
|
+
strategy = typeof modelSelection['strategy'] === 'string' ? modelSelection['strategy'] : undefined;
|
|
491
|
+
if (strategy !== 'caller-model' && strategy !== 'approved-profile') {
|
|
492
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `modelSelection.strategy must be "caller-model" or "approved-profile"; received ${describeKind(modelSelection['strategy'])}`));
|
|
493
|
+
}
|
|
494
|
+
const rawRequires = modelSelection['requires'];
|
|
495
|
+
if (!isPlainObject(rawRequires)) {
|
|
496
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'modelSelection.requires must carry inputModalities and outputModalities'));
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
refuseUnsafeKeys(rawRequires, 'modelSelection.requires', ['inputModalities', 'outputModalities']);
|
|
500
|
+
for (const [modalityKey, destination] of [['inputModalities', requires.inputModalities], ['outputModalities', requires.outputModalities]]) {
|
|
501
|
+
const value = rawRequires[modalityKey];
|
|
502
|
+
if (!Array.isArray(value) || value.length === 0 || !value.every(entry => entry === 'text' || entry === 'image' || entry === 'audio' || entry === 'video')) {
|
|
503
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `modelSelection.requires.${modalityKey} must be a non-empty array of the known modalities (text, image, audio, video); received ${describeKind(value)}`));
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
for (const entry of value)
|
|
507
|
+
destination.push(entry);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const profile = modelSelection['profile'];
|
|
512
|
+
if (strategy === 'approved-profile') {
|
|
513
|
+
if (typeof profile !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profile)) {
|
|
514
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'modelSelection.strategy "approved-profile" requires a profile name of letters, digits, dot, underscore or dash'));
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
extras.modelSelection = { strategy: 'approved-profile', profile };
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
else if (profile !== undefined) {
|
|
521
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'modelSelection.profile is only valid with strategy "approved-profile"'));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// --- context (HLM-05's frozen invariants) ------------------------------------
|
|
525
|
+
const context = raw['context'];
|
|
526
|
+
if (context !== undefined) {
|
|
527
|
+
if (!isPlainObject(context)) {
|
|
528
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `context must be the object {inheritConversation:false, includeTools:false}; received ${describeKind(context)}`));
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
refuseUnsafeKeys(context, 'context', ['inheritConversation', 'includeTools']);
|
|
532
|
+
if (context['inheritConversation'] !== false
|
|
533
|
+
|| context['includeTools'] !== false) {
|
|
534
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'context must be exactly {inheritConversation:false, includeTools:false}: a flag meaning "take the entire '
|
|
535
|
+
+ 'conversation" is not supported in this release (HLM-05)'));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// --- response (RCP-04) -------------------------------------------------------
|
|
540
|
+
const response = raw['response'];
|
|
541
|
+
if (response !== undefined) {
|
|
542
|
+
if (!isPlainObject(response)) {
|
|
543
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `response must be an object carrying "mode" and "field"; received ${describeKind(response)}`));
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
refuseUnsafeKeys(response, 'response', ['mode', 'field']);
|
|
547
|
+
const mode = response['mode'];
|
|
548
|
+
const field = response['field'];
|
|
549
|
+
if (mode !== 'text' && mode !== 'json') {
|
|
550
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `response.mode must be "text" or "json"; received ${describeKind(mode)}`));
|
|
551
|
+
}
|
|
552
|
+
if (typeof field !== 'string' || field.length === 0) {
|
|
553
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'response.field must be a non-empty payload field name'));
|
|
554
|
+
}
|
|
555
|
+
if (mode === 'text' || mode === 'json') {
|
|
556
|
+
if (typeof field === 'string' && field.length > 0) {
|
|
557
|
+
extras.response = { mode, field };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
// --- limits ------------------------------------------------------------------
|
|
563
|
+
const limits = raw['limits'];
|
|
564
|
+
let maxOutputTokens;
|
|
565
|
+
let maxImages = 0;
|
|
566
|
+
if (!isPlainObject(limits)) {
|
|
567
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'limits must be an object carrying at least maxOutputTokens and maxImages; received '
|
|
568
|
+
+ `${describeKind(limits)}`));
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
refuseUnsafeKeys(limits, 'limits', ['timeoutMs', 'maxInputTextBytes', 'maxImages', 'maxOutputTokens', 'maxAttempts', 'repairAttempts']);
|
|
572
|
+
maxOutputTokens = readInteger(limits['maxOutputTokens'], 'limits.maxOutputTokens', errors) ?? undefined;
|
|
573
|
+
if (maxOutputTokens !== undefined && (maxOutputTokens < 1 || maxOutputTokens > INITIAL_MAX_OUTPUT_TOKENS)) {
|
|
574
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `limits.maxOutputTokens must be between 1 and ${INITIAL_MAX_OUTPUT_TOKENS} (RES-10)`));
|
|
575
|
+
}
|
|
576
|
+
const rawMaxImages = readInteger(limits['maxImages'], 'limits.maxImages', errors);
|
|
577
|
+
if (rawMaxImages !== undefined && (rawMaxImages < 0 || rawMaxImages > INITIAL_MAX_IMAGE_INPUTS)) {
|
|
578
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `limits.maxImages must be between 0 and ${INITIAL_MAX_IMAGE_INPUTS} (RES-10)`));
|
|
579
|
+
}
|
|
580
|
+
else if (rawMaxImages !== undefined) {
|
|
581
|
+
maxImages = rawMaxImages;
|
|
582
|
+
}
|
|
583
|
+
// Accepted, validated, dropped (see the docstring above): the caller owns
|
|
584
|
+
// the deadline and the broker enforces the RES-10 ceilings.
|
|
585
|
+
for (const key of ['timeoutMs', 'maxInputTextBytes', 'maxAttempts', 'repairAttempts']) {
|
|
586
|
+
if (limits[key] !== undefined)
|
|
587
|
+
readInteger(limits[key], `limits.${key}`, errors);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
normalized.maxOutputTokens = maxOutputTokens;
|
|
591
|
+
normalized.imageInputs = { max: maxImages };
|
|
592
|
+
// Recording defaults ON; destinations default to the author's acceptance.
|
|
593
|
+
normalized.policy = { record: true, allowRemote: true };
|
|
594
|
+
// --- inputBindings ------------------------------------------------------------
|
|
595
|
+
const rawBindings = raw['inputBindings'];
|
|
596
|
+
const inputs = Object.create(null);
|
|
597
|
+
const bindingNames = new Set();
|
|
598
|
+
if (!Array.isArray(rawBindings) || rawBindings.length === 0) {
|
|
599
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `inputBindings must be a non-empty array of {kind, pointer, required, label}; received ${describeKind(rawBindings)}`));
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
for (const entry of rawBindings) {
|
|
603
|
+
if (!isPlainObject(entry)) {
|
|
604
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `inputBindings entries must be objects; received ${describeKind(entry)}`));
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
refuseUnsafeKeys(entry, 'inputBindings[]', ['kind', 'pointer', 'required', 'label', 'minLength', 'maxLength', 'minimum', 'maximum', 'enum', 'default', 'mediaTypes', 'maxBytes']);
|
|
608
|
+
const kind = entry['kind'];
|
|
609
|
+
const pointer = entry['pointer'];
|
|
610
|
+
if (kind !== 'text' && kind !== 'image' && kind !== 'json') {
|
|
611
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `inputBindings[].kind must be "text", "image" or "json"; received ${describeKind(kind)}`));
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
if (typeof pointer !== 'string' || !/^(\/[A-Za-z0-9_-]+)+$/.test(pointer)) {
|
|
615
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `inputBindings[].pointer must be a validated JSON Pointer of simple name segments (for example "/focus"); `
|
|
616
|
+
+ `received ${JSON.stringify(pointer)}. Reserved characters, prototype keys and escapes are refused (RCP-03)`));
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
const name = pointer.slice(1);
|
|
620
|
+
if (bindingNames.has(name)) {
|
|
621
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `inputBindings declare the pointer ${pointer} twice`));
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
bindingNames.add(name);
|
|
625
|
+
const required = entry['required'] === true;
|
|
626
|
+
const label = typeof entry['label'] === 'string' && entry['label'].length > 0 ? entry['label'] : undefined;
|
|
627
|
+
// The canonical serializer re-emits the richer constraint fields so the
|
|
628
|
+
// canonical round trip is LOSSLESS; the legacy parser below validates
|
|
629
|
+
// them with its own named refusals.
|
|
630
|
+
const constraints = {};
|
|
631
|
+
for (const key of ['minLength', 'maxLength', 'minimum', 'maximum', 'enum', 'default', 'mediaTypes', 'maxBytes']) {
|
|
632
|
+
if (entry[key] !== undefined)
|
|
633
|
+
constraints[key] = entry[key];
|
|
634
|
+
}
|
|
635
|
+
if (kind === 'image') {
|
|
636
|
+
inputs[name] = {
|
|
637
|
+
type: 'image', required,
|
|
638
|
+
...label === undefined ? {} : { description: label },
|
|
639
|
+
...constraints,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
// NOTE: no implicit minLength default — the canonical round trip must
|
|
644
|
+
// be LOSSLESS, and a legacy declaration without minLength parses to a
|
|
645
|
+
// frozen recipe without minLength, so the normalization must not invent
|
|
646
|
+
// one.
|
|
647
|
+
inputs[name] = {
|
|
648
|
+
type: 'string', required,
|
|
649
|
+
...label === undefined ? {} : { description: label },
|
|
650
|
+
...constraints,
|
|
651
|
+
...kind === 'json' ? { jsonSerialized: true } : {},
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
if (Object.keys(inputs).length === 0 && errors.length === 0) {
|
|
656
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'inputBindings must declare at least one binding'));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
normalized.inputs = inputs;
|
|
660
|
+
// --- promptTemplate rendering (RCP-03) --------------------------------------
|
|
661
|
+
// The canonical `instructions` are static prose; bindings are rendered BY
|
|
662
|
+
// POINTER. When the instructions do NOT already bind an input with the
|
|
663
|
+
// legacy `{{name}}` placeholder (the spec's §19.3 example has none), the
|
|
664
|
+
// normalized template appends one rendered line per unbound input — label or
|
|
665
|
+
// name, in declaration order — so the legacy "never binds" rule never fires
|
|
666
|
+
// on the canonical path and the bound VALUES reach the provider request.
|
|
667
|
+
if (typeof instructions === 'string') {
|
|
668
|
+
const bound = new Set();
|
|
669
|
+
for (const match of instructions.matchAll(/\{\{([^{}]*)\}\}/g)) {
|
|
670
|
+
bound.add((match[1] ?? '').trim());
|
|
671
|
+
}
|
|
672
|
+
const unboundLines = [];
|
|
673
|
+
for (const [name, declaration] of Object.entries(inputs)) {
|
|
674
|
+
if (bound.has(name))
|
|
675
|
+
continue;
|
|
676
|
+
const label = typeof declaration['description'] === 'string' && declaration['description'].length > 0
|
|
677
|
+
? declaration['description'] : name;
|
|
678
|
+
unboundLines.push(`- ${label}: {{${name}}}`);
|
|
679
|
+
}
|
|
680
|
+
normalized.promptTemplate = unboundLines.length === 0
|
|
681
|
+
? instructions
|
|
682
|
+
: `${instructions}\n\nSupplied context: the recipe instructions and the validated bound inputs only — no parent `
|
|
683
|
+
+ `conversation, system prompt, tools or memory (HLM-05)\n${unboundLines.join('\n')}`;
|
|
684
|
+
}
|
|
685
|
+
// The declared requires must be consistent with the declared bindings:
|
|
686
|
+
// an image binding needs "image" in inputModalities, and nothing else is
|
|
687
|
+
// permitted to appear (HLM-04: unknown is not supported).
|
|
688
|
+
const declaredImage = Object.values(inputs).some(declaration => declaration['type'] === 'image');
|
|
689
|
+
if (requires.inputModalities.length > 0) {
|
|
690
|
+
if (declaredImage && !requires.inputModalities.includes('image')) {
|
|
691
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'the recipe declares an image binding but modelSelection.requires.inputModalities omits "image"'));
|
|
692
|
+
}
|
|
693
|
+
if (!declaredImage && requires.inputModalities.includes('image')) {
|
|
694
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'modelSelection.requires.inputModalities claims "image" but no image binding is declared'));
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (requires.outputModalities.length > 0 && !requires.outputModalities.includes('text')) {
|
|
698
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'this build only produces text output; modelSelection.requires.outputModalities must include "text"'));
|
|
699
|
+
}
|
|
700
|
+
return { normalized, extras };
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Parse and validate a raw recipe.
|
|
704
|
+
*
|
|
705
|
+
* Every violation is collected and reported — one error per problem — because fixing a
|
|
706
|
+
* recipe one error per run is how an author gives up.
|
|
707
|
+
* @param raw - the parsed JSON of a recipe file, or any candidate value.
|
|
708
|
+
* @param options - the tool identity the recipe is being loaded as.
|
|
709
|
+
* @returns the frozen recipe, or every reason it is not one.
|
|
710
|
+
*/
|
|
711
|
+
export function parseRecipe(raw, options = {}) {
|
|
712
|
+
const errors = [];
|
|
713
|
+
if (!isPlainObject(raw)) {
|
|
714
|
+
return {
|
|
715
|
+
ok: false,
|
|
716
|
+
errors: [formatRecipeError('INVALID_RECIPE', `a recipe must be a JSON object; received ${describeKind(raw)}`)],
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
// A CANONICAL RCP-02 document is normalized into the legacy shape BEFORE the
|
|
720
|
+
// data-only gate below, because the canonical vocabulary's own `context`
|
|
721
|
+
// object carries the HLM-05 flag names (`inheritConversation`/`includeTools`)
|
|
722
|
+
// that the legacy gate refuses by name at any depth — the normalizer
|
|
723
|
+
// VALIDATES those flags are exactly false and then DROPS them, so the
|
|
724
|
+
// normalized document carries no forbidden keys and the legacy checks apply
|
|
725
|
+
// unchanged. The raw document is still name-gated: every top-level key that
|
|
726
|
+
// is not part of the canonical vocabulary is refused (unknown/forbidden/
|
|
727
|
+
// reserved) BEFORE any value is read.
|
|
728
|
+
const canonicalKeys = new Set([
|
|
729
|
+
'schemaVersion', 'kind', 'id', 'version', 'description', 'tool',
|
|
730
|
+
'modelSelection', 'instructions', 'inputBindings', 'response', 'limits', 'context',
|
|
731
|
+
]);
|
|
732
|
+
const isCanonical = canonicalKeys.has('instructions')
|
|
733
|
+
&& (Object.prototype.hasOwnProperty.call(raw, 'instructions')
|
|
734
|
+
|| Object.prototype.hasOwnProperty.call(raw, 'inputBindings')
|
|
735
|
+
|| Object.prototype.hasOwnProperty.call(raw, 'modelSelection')
|
|
736
|
+
|| Object.prototype.hasOwnProperty.call(raw, 'kind')
|
|
737
|
+
|| Object.prototype.hasOwnProperty.call(raw, 'schemaVersion'));
|
|
738
|
+
if (isCanonical) {
|
|
739
|
+
for (const key of Object.keys(raw)) {
|
|
740
|
+
if (!canonicalKeys.has(key)) {
|
|
741
|
+
if (FORBIDDEN_RECIPE_KEYS.includes(key)) {
|
|
742
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${key} is refused by name. A recipe is DATA, not a program: it cannot supply tools, messages, conversation `
|
|
743
|
+
+ 'history, attachments, memory or skills, because the broker builds the provider request from the recipe '
|
|
744
|
+
+ 'and the bound inputs alone (HLM-05, HLM-06). Remove the key; something that needs tools is a PTC '
|
|
745
|
+
+ 'program, which is a different artifact'));
|
|
746
|
+
}
|
|
747
|
+
else if (RESERVED_RECIPE_KEYS.includes(key)) {
|
|
748
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `${key} is a reserved prototype member and is refused by name (RCP-03)`));
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
errors.push(unknownKeyError('', key, [...canonicalKeys]));
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (errors.length > 0)
|
|
756
|
+
return { ok: false, errors };
|
|
757
|
+
const canonical = normalizeRecipeDocument(raw, errors);
|
|
758
|
+
if (errors.length > 0)
|
|
759
|
+
return { ok: false, errors };
|
|
760
|
+
const parsed = parseRecipe(canonical.normalized, options);
|
|
761
|
+
if (!parsed.ok)
|
|
762
|
+
return parsed;
|
|
763
|
+
const recipe = parsed.recipe;
|
|
764
|
+
const frozen = deepFreeze({
|
|
765
|
+
...recipe,
|
|
766
|
+
...canonical.extras.modelSelection === undefined ? {} : { modelSelection: canonical.extras.modelSelection },
|
|
767
|
+
...canonical.extras.response === undefined ? {} : { response: canonical.extras.response },
|
|
768
|
+
});
|
|
769
|
+
return { ok: true, recipe: frozen };
|
|
770
|
+
}
|
|
771
|
+
// Data-only first: this is where a function, a class instance or a context key is
|
|
772
|
+
// caught, and it must happen before any field is read, because a getter could
|
|
773
|
+
// otherwise run arbitrary code during validation.
|
|
774
|
+
checkDataOnly(raw, '', errors);
|
|
775
|
+
for (const key of unknownKeys(raw, RECIPE_TOP_LEVEL_KEYS)) {
|
|
776
|
+
errors.push(unknownKeyError('', key, RECIPE_TOP_LEVEL_KEYS));
|
|
777
|
+
}
|
|
778
|
+
const id = readNonEmptyString(raw['id'], 'id', errors);
|
|
779
|
+
if (id !== undefined && !/^[A-Za-z0-9@][A-Za-z0-9._@/-]*$/.test(id)) {
|
|
780
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `id ${JSON.stringify(id)} must be a stable identity of letters, digits, dot, dash, underscore, slash or a `
|
|
781
|
+
+ 'leading scope, with no whitespace'));
|
|
782
|
+
}
|
|
783
|
+
const version = readNonEmptyString(raw['version'], 'version', errors);
|
|
784
|
+
if (version !== undefined && !/^\d+\.\d+\.\d+$/.test(version)) {
|
|
785
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `version ${JSON.stringify(version)} must be semantic (x.y.z). A prompt change is a version change, because `
|
|
786
|
+
+ 'identical JSON shape does not mean identical semantics (RCP-06)'));
|
|
787
|
+
}
|
|
788
|
+
const description = readNonEmptyString(raw['description'], 'description', errors);
|
|
789
|
+
if (description !== undefined && description.trim().length < 10) {
|
|
790
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'description must be a real sentence, not a placeholder'));
|
|
791
|
+
}
|
|
792
|
+
const tool = parseToolIdentity(raw['tool'], errors, options);
|
|
793
|
+
// --- inputs -----------------------------------------------------------------
|
|
794
|
+
const rawInputs = raw['inputs'];
|
|
795
|
+
const inputs = {};
|
|
796
|
+
if (!isPlainObject(rawInputs)) {
|
|
797
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `inputs must be an object keyed by input name; received ${describeKind(rawInputs)}`));
|
|
798
|
+
}
|
|
799
|
+
else if (Object.keys(rawInputs).length === 0) {
|
|
800
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'inputs must declare at least one input. A tool that binds nothing is a constant, and a constant does not need '
|
|
801
|
+
+ 'a provider call to produce it'));
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
for (const [name, declaration] of Object.entries(rawInputs)) {
|
|
805
|
+
const parsed = parseInputDeclaration(name, declaration, errors);
|
|
806
|
+
if (parsed !== undefined)
|
|
807
|
+
inputs[name] = parsed;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// --- imageInputs ------------------------------------------------------------
|
|
811
|
+
let imageInputMax = 0;
|
|
812
|
+
const rawImageInputs = raw['imageInputs'];
|
|
813
|
+
if (!isPlainObject(rawImageInputs)) {
|
|
814
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `imageInputs must be an object carrying "max"; received ${describeKind(rawImageInputs)}`));
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
for (const key of unknownKeys(rawImageInputs, IMAGE_INPUTS_KEYS)) {
|
|
818
|
+
errors.push(unknownKeyError('imageInputs', key, IMAGE_INPUTS_KEYS));
|
|
819
|
+
}
|
|
820
|
+
const max = readInteger(rawImageInputs['max'], 'imageInputs.max', errors);
|
|
821
|
+
if (max !== undefined) {
|
|
822
|
+
if (max < 0) {
|
|
823
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'imageInputs.max must not be negative'));
|
|
824
|
+
}
|
|
825
|
+
else if (max > INITIAL_MAX_IMAGE_INPUTS) {
|
|
826
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `imageInputs.max is ${max}, over the ${INITIAL_MAX_IMAGE_INPUTS} that RES-10's initial per-leaf maximum `
|
|
827
|
+
+ 'permits for one invocation. A reviewed profile may lower this; a recipe may not raise it'));
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
imageInputMax = max;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
const declaredImages = Object.entries(inputs).filter(([, declaration]) => declaration.type === 'image');
|
|
835
|
+
if (declaredImages.length > 0 && imageInputMax === 0) {
|
|
836
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `the recipe declares ${declaredImages.length} image input(s) (${declaredImages.map(([name]) => name).join(', ')}) `
|
|
837
|
+
+ 'but imageInputs.max is 0, so no image could ever be bound'));
|
|
838
|
+
}
|
|
839
|
+
if (declaredImages.length > imageInputMax) {
|
|
840
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `the recipe declares ${declaredImages.length} image inputs but permits imageInputs.max ${imageInputMax}`));
|
|
841
|
+
}
|
|
842
|
+
// --- promptTemplate ---------------------------------------------------------
|
|
843
|
+
const promptTemplate = readNonEmptyString(raw['promptTemplate'], 'promptTemplate', errors);
|
|
844
|
+
if (promptTemplate !== undefined) {
|
|
845
|
+
/**
|
|
846
|
+
* The template is static instructions with bindings and nothing else.
|
|
847
|
+
*
|
|
848
|
+
* The check is deliberately two-sided: a placeholder must name a declared input
|
|
849
|
+
* (an unbound pointer cannot be satisfied, RCP-03), and template CODE must not
|
|
850
|
+
* appear at all, because RCP-02 admits no expressions, `eval`, shell, dynamic
|
|
851
|
+
* imports or remote prompt downloads.
|
|
852
|
+
*/
|
|
853
|
+
const names = new Set(Object.keys(inputs));
|
|
854
|
+
const bound = new Set();
|
|
855
|
+
for (const match of promptTemplate.matchAll(/\{\{([^{}]*)\}\}/g)) {
|
|
856
|
+
const name = (match[1] ?? '').trim();
|
|
857
|
+
bound.add(name);
|
|
858
|
+
if (!names.has(name)) {
|
|
859
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `promptTemplate binds {{${name}}}, which is not a declared input. The declared inputs are: `
|
|
860
|
+
+ `${names.size === 0 ? '(none)' : [...names].join(', ')}. A binding that names nothing is an unbound `
|
|
861
|
+
+ 'pointer and can never be satisfied (RCP-03)'));
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (![...names].every(name => bound.has(name))) {
|
|
865
|
+
const unused = [...names].filter(name => !bound.has(name));
|
|
866
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `promptTemplate never binds ${unused.join(', ')}, so those inputs would be validated and then silently `
|
|
867
|
+
+ 'dropped. Either bind them or remove them from the recipe'));
|
|
868
|
+
}
|
|
869
|
+
const leftover = promptTemplate.replace(/\{\{[^{}]*\}\}/g, '');
|
|
870
|
+
if (leftover.includes('{{') || leftover.includes('}}')) {
|
|
871
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'promptTemplate contains an unbalanced or nested brace; bindings are written exactly `{{name}}`'));
|
|
872
|
+
}
|
|
873
|
+
for (const syntax of ['${', '<%', '%>']) {
|
|
874
|
+
if (promptTemplate.includes(syntax)) {
|
|
875
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `promptTemplate contains the template expression ${JSON.stringify(syntax)}. A recipe is static data: `
|
|
876
|
+
+ 'RCP-02 admits no expressions, eval, shell, dynamic imports or remote prompt downloads'));
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
// --- maxOutputTokens --------------------------------------------------------
|
|
881
|
+
let maxOutputTokens = 0;
|
|
882
|
+
const rawMaxOutputTokens = readInteger(raw['maxOutputTokens'], 'maxOutputTokens', errors);
|
|
883
|
+
if (rawMaxOutputTokens !== undefined) {
|
|
884
|
+
if (rawMaxOutputTokens < 1) {
|
|
885
|
+
errors.push(formatRecipeError('INVALID_RECIPE', 'maxOutputTokens must be at least 1'));
|
|
886
|
+
}
|
|
887
|
+
else if (rawMaxOutputTokens > INITIAL_MAX_OUTPUT_TOKENS) {
|
|
888
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `maxOutputTokens is ${rawMaxOutputTokens}, over the ${INITIAL_MAX_OUTPUT_TOKENS} that RES-10's initial `
|
|
889
|
+
+ 'per-leaf maximum permits. A reviewed profile may lower this; a recipe may not raise it'));
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
maxOutputTokens = rawMaxOutputTokens;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// --- policy -----------------------------------------------------------------
|
|
896
|
+
let record = true;
|
|
897
|
+
let allowRemote = true;
|
|
898
|
+
const rawPolicy = raw['policy'];
|
|
899
|
+
if (!isPlainObject(rawPolicy)) {
|
|
900
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `policy must be an object carrying "record" and "allowRemote"; received ${describeKind(rawPolicy)}`));
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
for (const key of unknownKeys(rawPolicy, POLICY_KEYS))
|
|
904
|
+
errors.push(unknownKeyError('policy', key, POLICY_KEYS));
|
|
905
|
+
for (const key of POLICY_KEYS) {
|
|
906
|
+
const value = rawPolicy[key];
|
|
907
|
+
if (typeof value !== 'boolean') {
|
|
908
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `policy.${key} must be a boolean; a policy that is absent or ambiguous is one the host must guess at, and `
|
|
909
|
+
+ 'guessing at recording is how a request goes unrecorded (HLM-09)'));
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (typeof rawPolicy['record'] === 'boolean')
|
|
913
|
+
record = rawPolicy['record'];
|
|
914
|
+
if (typeof rawPolicy['allowRemote'] === 'boolean')
|
|
915
|
+
allowRemote = rawPolicy['allowRemote'];
|
|
916
|
+
}
|
|
917
|
+
if (errors.length > 0 || tool === undefined || promptTemplate === undefined) {
|
|
918
|
+
return { ok: false, errors: errors.length > 0 ? errors : [formatRecipeError('INVALID_RECIPE', 'the recipe is incomplete')] };
|
|
919
|
+
}
|
|
920
|
+
return {
|
|
921
|
+
ok: true,
|
|
922
|
+
recipe: deepFreeze({
|
|
923
|
+
id: id ?? '',
|
|
924
|
+
version: version ?? '',
|
|
925
|
+
description: description ?? '',
|
|
926
|
+
tool,
|
|
927
|
+
inputs,
|
|
928
|
+
promptTemplate,
|
|
929
|
+
maxOutputTokens,
|
|
930
|
+
imageInputs: { max: imageInputMax },
|
|
931
|
+
policy: { record, allowRemote },
|
|
932
|
+
}),
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Serialize a frozen recipe as the CANONICAL RCP-02 document.
|
|
937
|
+
*
|
|
938
|
+
* The output is what pack/create emit and what the schema's canonical branch
|
|
939
|
+
* validates: `schemaVersion`/`kind`/`modelSelection`/`instructions`/
|
|
940
|
+
* `inputBindings`/`response`/`limits`/`context` plus the identity keys
|
|
941
|
+
* (id/version/description/tool). The richer legacy constraint fields are
|
|
942
|
+
* re-emitted on the bindings, so canonical → parse → canonical is LOSSLESS.
|
|
943
|
+
* `modelSelection.strategy` defaults to `caller-model` with the derived
|
|
944
|
+
* requires; an `approved-profile` selection is preserved verbatim.
|
|
945
|
+
*
|
|
946
|
+
* @param recipe - the frozen recipe.
|
|
947
|
+
* @returns the canonical document.
|
|
948
|
+
*/
|
|
949
|
+
export function toCanonicalRecipe(recipe) {
|
|
950
|
+
const declaredImage = Object.values(recipe.inputs).some(declaration => declaration.type === 'image');
|
|
951
|
+
return {
|
|
952
|
+
schemaVersion: '1.0.0',
|
|
953
|
+
kind: 'harness-model',
|
|
954
|
+
id: recipe.id,
|
|
955
|
+
version: recipe.version,
|
|
956
|
+
description: recipe.description,
|
|
957
|
+
tool: { name: recipe.tool.name, contractId: recipe.tool.contractId },
|
|
958
|
+
modelSelection: recipe.modelSelection ?? {
|
|
959
|
+
strategy: 'caller-model',
|
|
960
|
+
requires: {
|
|
961
|
+
inputModalities: declaredImage ? ['text', 'image'] : ['text'],
|
|
962
|
+
outputModalities: ['text'],
|
|
963
|
+
},
|
|
964
|
+
},
|
|
965
|
+
instructions: recipe.promptTemplate,
|
|
966
|
+
inputBindings: Object.entries(recipe.inputs).map(([name, declaration]) => ({
|
|
967
|
+
kind: declaration.type === 'image' ? 'image' : declaration.jsonSerialized === true ? 'json' : 'text',
|
|
968
|
+
pointer: `/${name}`,
|
|
969
|
+
required: declaration.required,
|
|
970
|
+
...declaration.description === undefined ? {} : { label: declaration.description },
|
|
971
|
+
...declaration.minLength === undefined ? {} : { minLength: declaration.minLength },
|
|
972
|
+
...declaration.maxLength === undefined ? {} : { maxLength: declaration.maxLength },
|
|
973
|
+
...declaration.minimum === undefined ? {} : { minimum: declaration.minimum },
|
|
974
|
+
...declaration.maximum === undefined ? {} : { maximum: declaration.maximum },
|
|
975
|
+
...declaration.enum === undefined ? {} : { enum: declaration.enum },
|
|
976
|
+
...declaration.default === undefined ? {} : { default: declaration.default },
|
|
977
|
+
...declaration.mediaTypes === undefined ? {} : { mediaTypes: declaration.mediaTypes },
|
|
978
|
+
...declaration.maxBytes === undefined ? {} : { maxBytes: declaration.maxBytes },
|
|
979
|
+
})),
|
|
980
|
+
...recipe.response === undefined ? {} : { response: recipe.response },
|
|
981
|
+
limits: { maxOutputTokens: recipe.maxOutputTokens, maxImages: recipe.imageInputs.max },
|
|
982
|
+
context: { inheritConversation: false, includeTools: false },
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
/** Validate and read the tool identity, including the identity-claim refusal. */
|
|
986
|
+
function parseToolIdentity(raw, errors, options) {
|
|
987
|
+
if (!isPlainObject(raw)) {
|
|
988
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `tool must be an object; received ${describeKind(raw)}`));
|
|
989
|
+
return undefined;
|
|
990
|
+
}
|
|
991
|
+
for (const key of unknownKeys(raw, TOOL_KEYS))
|
|
992
|
+
errors.push(unknownKeyError('tool', key, TOOL_KEYS));
|
|
993
|
+
const name = readNonEmptyString(raw['name'], 'tool.name', errors);
|
|
994
|
+
if (name !== undefined && !/^[a-z][a-z0-9_]*$/.test(name)) {
|
|
995
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `tool.name ${JSON.stringify(name)} must be lower snake_case`));
|
|
996
|
+
}
|
|
997
|
+
const contractId = readNonEmptyString(raw['contractId'], 'tool.contractId', errors);
|
|
998
|
+
if (contractId !== undefined && !CONTRACT_IDENTITY.test(contractId)) {
|
|
999
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `tool.contractId ${JSON.stringify(contractId)} must be a dotted lower-case identity (for example "text.title") or `
|
|
1000
|
+
+ 'a scoped third-party identity (for example "@acme/tool-mode-image-review/review")'));
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* A recipe implements the tool it is loaded as, and no other.
|
|
1004
|
+
*
|
|
1005
|
+
* Without this check a recipe file could be swapped for another tool's recipe and
|
|
1006
|
+
* inherit that tool's authorization while running different instructions — the recipe
|
|
1007
|
+
* generation and the contract identity would silently disagree (RCP-06).
|
|
1008
|
+
*/
|
|
1009
|
+
if (name !== undefined && options.expectedToolName !== undefined && name !== options.expectedToolName) {
|
|
1010
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `the recipe names the tool "${name}" but it is being loaded as the implementation of "${options.expectedToolName}". `
|
|
1011
|
+
+ 'A recipe cannot claim another tool\'s identity: the authorization, the contract and the limits all belong to '
|
|
1012
|
+
+ 'the tool it declares (RCP-06)'));
|
|
1013
|
+
}
|
|
1014
|
+
if (contractId !== undefined && options.expectedContractId !== undefined && contractId !== options.expectedContractId) {
|
|
1015
|
+
errors.push(formatRecipeError('INVALID_RECIPE', `the recipe names the contract "${contractId}" but it is being loaded as the implementation of `
|
|
1016
|
+
+ `"${options.expectedContractId}"`));
|
|
1017
|
+
}
|
|
1018
|
+
if (name === undefined || contractId === undefined)
|
|
1019
|
+
return undefined;
|
|
1020
|
+
return { name, contractId };
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Bind caller arguments to a recipe's declared inputs.
|
|
1024
|
+
*
|
|
1025
|
+
* Every problem is reported, not the first: an author fixing a binding one field per run
|
|
1026
|
+
* is an author who ships the field they gave up on. Nothing is coerced — a string "3" is
|
|
1027
|
+
* not the integer 3, because a silently coerced binding is a binding the caller did not
|
|
1028
|
+
* supply.
|
|
1029
|
+
* @param recipe - the recipe whose inputs are being bound.
|
|
1030
|
+
* @param args - the caller's arguments.
|
|
1031
|
+
* @returns the bound values, or every reason they do not satisfy the recipe.
|
|
1032
|
+
*/
|
|
1033
|
+
export function validateRecipeInputs(recipe, args) {
|
|
1034
|
+
if (!isPlainObject(args)) {
|
|
1035
|
+
return {
|
|
1036
|
+
ok: false,
|
|
1037
|
+
errors: [formatRecipeError('RECIPE_BINDING_ERROR', `the arguments for ${recipe.tool.name} must be an object; received ${describeKind(args)}`)],
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
const errors = [];
|
|
1041
|
+
const declaredNames = Object.keys(recipe.inputs);
|
|
1042
|
+
for (const supplied of Object.keys(args)) {
|
|
1043
|
+
if (!Object.prototype.hasOwnProperty.call(recipe.inputs, supplied)) {
|
|
1044
|
+
errors.push(formatRecipeError('RECIPE_BINDING_ERROR', `"${supplied}" is not an input of ${recipe.tool.name}. The declared inputs are: ${declaredNames.join(', ')}. `
|
|
1045
|
+
+ 'A recipe\'s inputs are closed, so a caller cannot add one — and cannot widen a limit through the argument '
|
|
1046
|
+
+ 'object (RCP-05)'));
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
const values = {};
|
|
1050
|
+
for (const [name, declaration] of Object.entries(recipe.inputs)) {
|
|
1051
|
+
const supplied = Object.prototype.hasOwnProperty.call(args, name) ? args[name] : undefined;
|
|
1052
|
+
if (supplied === undefined) {
|
|
1053
|
+
if (declaration.default !== undefined) {
|
|
1054
|
+
values[name] = declaration.default;
|
|
1055
|
+
}
|
|
1056
|
+
else if (declaration.required) {
|
|
1057
|
+
errors.push(formatRecipeError('RECIPE_BINDING_ERROR', `the required input "${name}" was not supplied`));
|
|
1058
|
+
}
|
|
1059
|
+
// An omitted optional input is OMITTED, never stringified: RCP-03 requires that a
|
|
1060
|
+
// missing optional binding drop that user content rather than contribute the text
|
|
1061
|
+
// "undefined" to the prompt.
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
const bound = bindValue(name, declaration, supplied, errors);
|
|
1065
|
+
if (bound !== undefined)
|
|
1066
|
+
values[name] = bound;
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* There is no image-count check here, and that is deliberate rather than missing.
|
|
1070
|
+
*
|
|
1071
|
+
* The inputs are a CLOSED name-keyed map, so the most images a caller can bind is the
|
|
1072
|
+
* number of image inputs the recipe declares — and parseRecipe already refuses a recipe
|
|
1073
|
+
* declaring more image inputs than its own `imageInputs.max` permits. A check here would
|
|
1074
|
+
* be unreachable, and an unreachable budget check is worse than none: it reads as
|
|
1075
|
+
* enforcement. The RUNTIME ceiling, which a narrowed host policy can lower below the
|
|
1076
|
+
* recipe's figure, is enforced in the broker where it can actually differ.
|
|
1077
|
+
*/
|
|
1078
|
+
return errors.length > 0 ? { ok: false, errors } : { ok: true, values };
|
|
1079
|
+
}
|
|
1080
|
+
/** Bind one supplied value against its declaration. */
|
|
1081
|
+
function bindValue(name, declaration, supplied, errors) {
|
|
1082
|
+
const path = `"${name}"`;
|
|
1083
|
+
if (declaration.type === 'image')
|
|
1084
|
+
return bindImage(name, declaration, supplied, errors);
|
|
1085
|
+
// A canonical `inputBindings` kind "json" binding (RCP-03): the caller
|
|
1086
|
+
// supplies a data-only object or array, which is JSON-serialized into the
|
|
1087
|
+
// bound string. Serialization is data-only — never evaluated as instructions
|
|
1088
|
+
// or code.
|
|
1089
|
+
if (declaration.jsonSerialized === true) {
|
|
1090
|
+
if (!isPlainObject(supplied) && !Array.isArray(supplied)) {
|
|
1091
|
+
errors.push(formatRecipeError('RECIPE_BINDING_ERROR', `${path} must be a JSON-serializable object or array (the binding kind is "json"); received ${describeKind(supplied)}`));
|
|
1092
|
+
return undefined;
|
|
1093
|
+
}
|
|
1094
|
+
const serializationErrors = [];
|
|
1095
|
+
checkDataOnly(supplied, path, serializationErrors);
|
|
1096
|
+
for (const error of serializationErrors)
|
|
1097
|
+
errors.push(error);
|
|
1098
|
+
return JSON.stringify(supplied);
|
|
1099
|
+
}
|
|
1100
|
+
const scalar = typeof supplied === 'string' || typeof supplied === 'number' || typeof supplied === 'boolean';
|
|
1101
|
+
if (!scalar) {
|
|
1102
|
+
errors.push(formatRecipeError('RECIPE_BINDING_ERROR', `${path} must be a ${declaration.type}; received ${describeKind(supplied)}`));
|
|
1103
|
+
return undefined;
|
|
1104
|
+
}
|
|
1105
|
+
if (!matchesScalarType(declaration.type, supplied)) {
|
|
1106
|
+
errors.push(formatRecipeError('RECIPE_BINDING_ERROR', `${path} must be a ${declaration.type}; received ${describeKind(supplied)}. Values are not coerced, because a `
|
|
1107
|
+
+ 'coerced binding is a binding the caller did not supply'));
|
|
1108
|
+
return undefined;
|
|
1109
|
+
}
|
|
1110
|
+
if (declaration.enum !== undefined && !declaration.enum.includes(supplied)) {
|
|
1111
|
+
errors.push(formatRecipeError('RECIPE_BINDING_ERROR', `${path} must be one of ${declaration.enum.map(member => JSON.stringify(member)).join(', ')}; received `
|
|
1112
|
+
+ `${JSON.stringify(supplied)}`));
|
|
1113
|
+
return undefined;
|
|
1114
|
+
}
|
|
1115
|
+
if (typeof supplied === 'string') {
|
|
1116
|
+
if (declaration.minLength !== undefined && supplied.length < declaration.minLength) {
|
|
1117
|
+
errors.push(formatRecipeError('RECIPE_BINDING_ERROR', `${path} is ${supplied.length} characters, under the declared minLength ${declaration.minLength}`));
|
|
1118
|
+
return undefined;
|
|
1119
|
+
}
|
|
1120
|
+
if (declaration.maxLength !== undefined && supplied.length > declaration.maxLength) {
|
|
1121
|
+
errors.push(formatRecipeError('RECIPE_BINDING_ERROR', `${path} is ${supplied.length} characters, over the declared maxLength ${declaration.maxLength}. The binding is `
|
|
1122
|
+
+ 'refused rather than truncated, because a truncated input is not the input the caller supplied'));
|
|
1123
|
+
return undefined;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
if (typeof supplied === 'number') {
|
|
1127
|
+
if (declaration.minimum !== undefined && supplied < declaration.minimum) {
|
|
1128
|
+
errors.push(formatRecipeError('RECIPE_BINDING_ERROR', `${path} is under the declared minimum ${declaration.minimum}`));
|
|
1129
|
+
return undefined;
|
|
1130
|
+
}
|
|
1131
|
+
if (declaration.maximum !== undefined && supplied > declaration.maximum) {
|
|
1132
|
+
errors.push(formatRecipeError('RECIPE_BINDING_ERROR', `${path} is over the declared maximum ${declaration.maximum}`));
|
|
1133
|
+
return undefined;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return supplied;
|
|
1137
|
+
}
|
|
1138
|
+
/** Bind one image, which is the media type plus the bytes. */
|
|
1139
|
+
function bindImage(name, declaration, supplied, errors) {
|
|
1140
|
+
const path = `"${name}"`;
|
|
1141
|
+
if (!isPlainObject(supplied)) {
|
|
1142
|
+
errors.push(formatRecipeError('RECIPE_BINDING_ERROR', `${path} must be a typed image ({ mediaType, bytes }); received ${describeKind(supplied)}. A path or a URL is not `
|
|
1143
|
+
+ 'an image binding: the request must be fully described by the recipe and the bound inputs (HLM-05)'));
|
|
1144
|
+
return undefined;
|
|
1145
|
+
}
|
|
1146
|
+
for (const key of unknownKeys(supplied, ['mediaType', 'bytes'])) {
|
|
1147
|
+
errors.push(formatRecipeError('RECIPE_BINDING_ERROR', `${path}.${key} is not part of a typed image binding`));
|
|
1148
|
+
}
|
|
1149
|
+
const mediaType = supplied['mediaType'];
|
|
1150
|
+
if (typeof mediaType !== 'string' || !mediaType.startsWith('image/')) {
|
|
1151
|
+
errors.push(formatRecipeError('UNSUPPORTED_MODALITY', `${path}.mediaType must be an image media type (for example "image/png"); received ${JSON.stringify(mediaType)}. `
|
|
1152
|
+
+ 'A non-image media type bound to an image input would be sent as an image the provider cannot read'));
|
|
1153
|
+
return undefined;
|
|
1154
|
+
}
|
|
1155
|
+
if (declaration.mediaTypes !== undefined && !declaration.mediaTypes.includes(mediaType)) {
|
|
1156
|
+
errors.push(formatRecipeError('UNSUPPORTED_MODALITY', `${path}.mediaType ${JSON.stringify(mediaType)} is not among the admitted types ${declaration.mediaTypes.join(', ')}`));
|
|
1157
|
+
return undefined;
|
|
1158
|
+
}
|
|
1159
|
+
const bytes = supplied['bytes'];
|
|
1160
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
1161
|
+
errors.push(formatRecipeError('RECIPE_BINDING_ERROR', `${path}.bytes must be a Uint8Array of the image's actual bytes; received ${describeKind(bytes)}`));
|
|
1162
|
+
return undefined;
|
|
1163
|
+
}
|
|
1164
|
+
if (bytes.length === 0) {
|
|
1165
|
+
errors.push(formatRecipeError('RECIPE_BINDING_ERROR', `${path}.bytes is empty; an empty buffer is not an image`));
|
|
1166
|
+
return undefined;
|
|
1167
|
+
}
|
|
1168
|
+
if (declaration.maxBytes !== undefined && bytes.length > declaration.maxBytes) {
|
|
1169
|
+
errors.push(formatRecipeError('MODEL_BUDGET_EXCEEDED', `${path}.bytes is ${bytes.length} bytes, over the declared maxBytes ${declaration.maxBytes}`));
|
|
1170
|
+
return undefined;
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* The bytes are COPIED, not aliased.
|
|
1174
|
+
*
|
|
1175
|
+
* HLM-09 requires model requests to use immutable captured inputs. Handing the
|
|
1176
|
+
* provider a buffer the caller still holds would let a mutation between dispatch and
|
|
1177
|
+
* dispatch change what the model received, which is exactly the kind of evidence gap
|
|
1178
|
+
* an auxiliary-call journal cannot describe after the fact.
|
|
1179
|
+
*/
|
|
1180
|
+
return { mediaType, bytes: Uint8Array.from(bytes) };
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Render the prompt from validated bindings (RCP-02, RCP-03).
|
|
1184
|
+
*
|
|
1185
|
+
* A line that binds an absent optional input is DROPPED, whole. RCP-03 says a missing
|
|
1186
|
+
* optional focus omits that user content rather than stringifying `undefined`, and in a
|
|
1187
|
+
* flat string the only way to omit a labelled section is to omit its line.
|
|
1188
|
+
*
|
|
1189
|
+
* A bound image contributes a bounded REFERENCE — its media type and byte count — never
|
|
1190
|
+
* its bytes: MOD-06 forbids concatenating raw byte data into text.
|
|
1191
|
+
* @param recipe - the recipe to render.
|
|
1192
|
+
* @param values - bindings from {@link validateRecipeInputs}.
|
|
1193
|
+
* @returns the prompt text.
|
|
1194
|
+
* @throws Error when a required binding has no value, which means the caller skipped
|
|
1195
|
+
* validation; the broker always validates first.
|
|
1196
|
+
*/
|
|
1197
|
+
export function renderPrompt(recipe, values) {
|
|
1198
|
+
const rendered = [];
|
|
1199
|
+
for (const line of recipe.promptTemplate.split('\n')) {
|
|
1200
|
+
const names = placeholdersIn(line);
|
|
1201
|
+
if (names.length === 0) {
|
|
1202
|
+
rendered.push(line);
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
let omit = false;
|
|
1206
|
+
let text = line;
|
|
1207
|
+
for (const name of names) {
|
|
1208
|
+
const declaration = recipe.inputs[name];
|
|
1209
|
+
if (declaration === undefined) {
|
|
1210
|
+
// Unreachable for a parsed recipe; parseRecipe refuses an unbound placeholder.
|
|
1211
|
+
throw new Error(`the prompt binds {{${name}}}, which is not a declared input of ${recipe.tool.name}`);
|
|
1212
|
+
}
|
|
1213
|
+
const value = values[name];
|
|
1214
|
+
if (value === undefined) {
|
|
1215
|
+
if (declaration.required) {
|
|
1216
|
+
throw new Error(`renderPrompt requires validated bindings: the required input "${name}" has no value. Call `
|
|
1217
|
+
+ 'validateRecipeInputs first; the broker always does');
|
|
1218
|
+
}
|
|
1219
|
+
omit = true;
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
const substitute = declaration.type === 'image' ? imageReference(value) : String(value);
|
|
1223
|
+
text = text.split(`{{${name}}}`).join(substitute);
|
|
1224
|
+
}
|
|
1225
|
+
if (!omit)
|
|
1226
|
+
rendered.push(text);
|
|
1227
|
+
}
|
|
1228
|
+
return rendered.join('\n');
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* The textual reference a bound image contributes to a prompt (HLM-09, MOD-06).
|
|
1232
|
+
* @param image - the bound image.
|
|
1233
|
+
* @returns a bounded reference naming the media type and size.
|
|
1234
|
+
*/
|
|
1235
|
+
export function imageReference(image) {
|
|
1236
|
+
return `[image ${image.mediaType}, ${image.bytes.length} bytes]`;
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Project a recipe's input declarations into a JSON Schema (DECL-01).
|
|
1240
|
+
*
|
|
1241
|
+
* This is what makes one declaration serve three surfaces: the tool contract, the DSH
|
|
1242
|
+
* parameter projection and the MCP schema all read this object, so there is no second
|
|
1243
|
+
* description of the inputs to drift from the recipe. The schema is closed, because an
|
|
1244
|
+
* open object would advertise that a caller may add inputs the recipe does not have.
|
|
1245
|
+
* @param recipe - the recipe to project.
|
|
1246
|
+
* @returns a JSON Schema object for the tool's arguments.
|
|
1247
|
+
*/
|
|
1248
|
+
export function inputSchemaFor(recipe) {
|
|
1249
|
+
const properties = {};
|
|
1250
|
+
const required = [];
|
|
1251
|
+
for (const [name, declaration] of Object.entries(recipe.inputs)) {
|
|
1252
|
+
const property = declaration.type === 'image'
|
|
1253
|
+
// An image binding is an object carrying its media type and bytes; the schema says
|
|
1254
|
+
// which media types are admitted, so a caller learns the bound from the contract.
|
|
1255
|
+
? {
|
|
1256
|
+
type: 'object',
|
|
1257
|
+
additionalProperties: false,
|
|
1258
|
+
required: ['mediaType', 'bytes'],
|
|
1259
|
+
properties: {
|
|
1260
|
+
mediaType: { type: 'string', ...(declaration.mediaTypes === undefined ? {} : { enum: declaration.mediaTypes }) },
|
|
1261
|
+
bytes: { type: 'string', contentEncoding: 'base64', description: 'The image bytes.' },
|
|
1262
|
+
},
|
|
1263
|
+
}
|
|
1264
|
+
: { type: declaration.type };
|
|
1265
|
+
if (declaration.description !== undefined)
|
|
1266
|
+
property['description'] = declaration.description;
|
|
1267
|
+
if (declaration.minLength !== undefined)
|
|
1268
|
+
property['minLength'] = declaration.minLength;
|
|
1269
|
+
if (declaration.maxLength !== undefined)
|
|
1270
|
+
property['maxLength'] = declaration.maxLength;
|
|
1271
|
+
if (declaration.minimum !== undefined)
|
|
1272
|
+
property['minimum'] = declaration.minimum;
|
|
1273
|
+
if (declaration.maximum !== undefined)
|
|
1274
|
+
property['maximum'] = declaration.maximum;
|
|
1275
|
+
if (declaration.enum !== undefined)
|
|
1276
|
+
property['enum'] = [...declaration.enum];
|
|
1277
|
+
if (declaration.default !== undefined)
|
|
1278
|
+
property['default'] = declaration.default;
|
|
1279
|
+
properties[name] = property;
|
|
1280
|
+
if (declaration.required)
|
|
1281
|
+
required.push(name);
|
|
1282
|
+
}
|
|
1283
|
+
return { type: 'object', additionalProperties: false, required, properties };
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Describe a recipe and its actual executor possibilities (RCP-10).
|
|
1287
|
+
*
|
|
1288
|
+
* The description states what the invocation will really do — the effective token
|
|
1289
|
+
* ceiling after narrowing, whether a remote destination is possible at all, and that no
|
|
1290
|
+
* parent context is supplied — because discovery that describes the recipe's wishes
|
|
1291
|
+
* rather than the host's limits tells a caller it can do something it cannot.
|
|
1292
|
+
* @param recipe - the recipe to describe.
|
|
1293
|
+
* @param policy - the host policy in force.
|
|
1294
|
+
* @returns a bounded, honest description.
|
|
1295
|
+
*/
|
|
1296
|
+
export function describeRecipe(recipe, policy) {
|
|
1297
|
+
const images = Object.entries(recipe.inputs).filter(([, declaration]) => declaration.type === 'image').length;
|
|
1298
|
+
const ceiling = Math.min(recipe.maxOutputTokens, policy.outputTokens);
|
|
1299
|
+
const imageCeiling = Math.min(recipe.imageInputs.max, policy.imageInputs, INITIAL_MAX_IMAGE_INPUTS);
|
|
1300
|
+
return [
|
|
1301
|
+
`${recipe.tool.name} (contract ${recipe.tool.contractId}) — ${recipe.description}`,
|
|
1302
|
+
`Recipe ${recipe.id}@${recipe.version}; ${Object.keys(recipe.inputs).length} declared input(s), ${images} image input(s).`,
|
|
1303
|
+
`Limits in force: at most ${ceiling} output tokens (recipe ${recipe.maxOutputTokens}, host ${policy.outputTokens}), `
|
|
1304
|
+
+ `at most ${imageCeiling} image input(s) (recipe ${recipe.imageInputs.max}, host ${policy.imageInputs}).`,
|
|
1305
|
+
`Destinations: ${policy.remoteDestinations ? (recipe.policy.allowRemote ? 'local or remote' : 'local only, by the recipe') : 'local only, by the host policy'}.`,
|
|
1306
|
+
`Supplied context: the recipe instructions and the validated bound inputs only — no parent conversation, system `
|
|
1307
|
+
+ `prompt, tools, skills, attachments or memory (HLM-05). Recording: ${recipe.policy.record ? 'on' : 'off by the recipe'}.`,
|
|
1308
|
+
].join(' ');
|
|
1309
|
+
}
|
|
1310
|
+
//# sourceMappingURL=recipe-format.js.map
|