@arnilo/prism 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +19 -2
  2. package/README.md +17 -7
  3. package/dist/agent-definitions.d.ts +12 -0
  4. package/dist/agent-definitions.js +131 -0
  5. package/dist/agent-loops.d.ts +14 -0
  6. package/dist/agent-loops.js +161 -0
  7. package/dist/agents.js +263 -76
  8. package/dist/cache-helpers.d.ts +28 -0
  9. package/dist/cache-helpers.js +73 -0
  10. package/dist/cli-runner.d.ts +38 -2
  11. package/dist/cli-runner.js +167 -5
  12. package/dist/compaction.js +2 -0
  13. package/dist/config.js +47 -12
  14. package/dist/contracts.d.ts +581 -6
  15. package/dist/contracts.js +41 -1
  16. package/dist/contribution-parsing.d.ts +19 -0
  17. package/dist/contribution-parsing.js +124 -0
  18. package/dist/contributions.d.ts +13 -3
  19. package/dist/contributions.js +96 -20
  20. package/dist/extensions.js +3 -0
  21. package/dist/index.d.ts +19 -9
  22. package/dist/index.js +10 -4
  23. package/dist/input.d.ts +7 -1
  24. package/dist/input.js +52 -11
  25. package/dist/instruction-injection.d.ts +28 -0
  26. package/dist/instruction-injection.js +55 -0
  27. package/dist/manifests.d.ts +1 -1
  28. package/dist/manifests.js +3 -3
  29. package/dist/models.d.ts +4 -1
  30. package/dist/models.js +5 -2
  31. package/dist/node/agent-definitions.d.ts +98 -0
  32. package/dist/node/agent-definitions.js +389 -0
  33. package/dist/node/contribution-discovery.d.ts +17 -0
  34. package/dist/node/contribution-discovery.js +163 -0
  35. package/dist/node/instruction-injectors.d.ts +32 -0
  36. package/dist/node/instruction-injectors.js +72 -0
  37. package/dist/node/session-store-jsonl.d.ts +1 -1
  38. package/dist/node/session-store-jsonl.js +42 -4
  39. package/dist/node/system-project-prompts.d.ts +30 -0
  40. package/dist/node/system-project-prompts.js +53 -0
  41. package/dist/provider-events.d.ts +3 -1
  42. package/dist/provider-events.js +34 -0
  43. package/dist/provider-request-policy.js +15 -1
  44. package/dist/providers/openai-compatible.js +1 -1
  45. package/dist/providers.d.ts +6 -2
  46. package/dist/providers.js +15 -1
  47. package/dist/redaction.d.ts +2 -1
  48. package/dist/redaction.js +3 -0
  49. package/dist/registry-options.d.ts +5 -0
  50. package/dist/registry-options.js +5 -0
  51. package/dist/rpc.d.ts +6 -2
  52. package/dist/rpc.js +71 -13
  53. package/dist/session-stores.d.ts +3 -1
  54. package/dist/session-stores.js +67 -6
  55. package/dist/skills.d.ts +4 -1
  56. package/dist/skills.js +3 -1
  57. package/dist/system-prompts.js +6 -2
  58. package/dist/testing/compaction-conformance.d.ts +17 -0
  59. package/dist/testing/compaction-conformance.js +61 -0
  60. package/dist/testing/extension-conformance.d.ts +26 -0
  61. package/dist/testing/extension-conformance.js +55 -0
  62. package/dist/testing/provider-conformance.d.ts +7 -0
  63. package/dist/testing/provider-conformance.js +18 -31
  64. package/dist/testing/session-store-conformance.d.ts +20 -0
  65. package/dist/testing/session-store-conformance.js +92 -0
  66. package/dist/testing/tool-conformance.d.ts +39 -0
  67. package/dist/testing/tool-conformance.js +79 -0
  68. package/dist/tools.d.ts +7 -2
  69. package/dist/tools.js +50 -13
  70. package/docs/agent-definitions.md +251 -0
  71. package/docs/agent-events.md +199 -0
  72. package/docs/agent-loops.md +217 -0
  73. package/docs/agent-session-runtime.md +20 -8
  74. package/docs/cli-rpc.md +39 -4
  75. package/docs/coding-agent-tools.md +208 -0
  76. package/docs/compaction-and-retry.md +2 -2
  77. package/docs/compaction-conformance.md +76 -0
  78. package/docs/compaction-llm.md +6 -3
  79. package/docs/compaction-observational-memory.md +4 -4
  80. package/docs/configuration-and-manifests.md +6 -1
  81. package/docs/context-and-skills.md +79 -6
  82. package/docs/contribution-discovery.md +149 -0
  83. package/docs/contribution-registries.md +9 -6
  84. package/docs/credentials-and-redaction.md +2 -0
  85. package/docs/customization.md +191 -0
  86. package/docs/database-persistence.md +407 -0
  87. package/docs/extension-authoring.md +193 -0
  88. package/docs/extension-conformance.md +80 -0
  89. package/docs/extensions.md +6 -0
  90. package/docs/host-security.md +141 -0
  91. package/docs/index.md +41 -19
  92. package/docs/input-and-prompt-assembly.md +19 -3
  93. package/docs/instruction-injection.md +183 -0
  94. package/docs/migration.md +201 -0
  95. package/docs/model-registry.md +122 -0
  96. package/docs/node-jsonl-session-store.md +5 -4
  97. package/docs/performance.md +127 -0
  98. package/docs/provider-caching.md +206 -0
  99. package/docs/provider-conformance.md +32 -5
  100. package/docs/provider-layer.md +51 -11
  101. package/docs/provider-packages.md +65 -5
  102. package/docs/provider-request-policies.md +113 -0
  103. package/docs/providers/kimi.md +22 -0
  104. package/docs/providers/neuralwatt.md +388 -0
  105. package/docs/providers/openai-compatible.md +1 -0
  106. package/docs/providers/openai.md +21 -0
  107. package/docs/providers/opencode-go.md +31 -3
  108. package/docs/providers/openrouter.md +29 -0
  109. package/docs/providers/zai.md +17 -0
  110. package/docs/public-contracts.md +87 -12
  111. package/docs/release-and-install.md +79 -27
  112. package/docs/runs-and-usage.md +236 -0
  113. package/docs/session-store-conformance.md +78 -0
  114. package/docs/session-stores-and-branching.md +10 -6
  115. package/docs/session-stores.md +126 -0
  116. package/docs/settings-auth-trust-security.md +18 -4
  117. package/docs/structured-output.md +247 -0
  118. package/docs/system-prompts.md +104 -2
  119. package/docs/tool-conformance.md +87 -0
  120. package/docs/tools.md +65 -8
  121. package/package.json +36 -2
@@ -0,0 +1,92 @@
1
+ // ponytail: dependency-free, runner-agnostic conformance helper for the
2
+ // SessionStore adapter contract. Hosts implementing a DB-backed SessionStore
3
+ // (see examples/external-app-db-backed.ts) call this once to assert the
4
+ // append/idempotency/conflict/branch invariants that the core memory and JSONL
5
+ // stores already satisfy. Mirrors the assertion shape already repeated across
6
+ // src/__tests__/session-stores.test.ts and node-session-store-jsonl.test.ts so
7
+ // adapter authors do not re-derive them. Throws plain Error; no test runner.
8
+ import { isSessionAppendConflict } from "../contracts.js";
9
+ /**
10
+ * Assert that a `SessionStore` implementation satisfies the core adapter
11
+ * contract: round-trip append/list, duplicate-entry-id rejection,
12
+ * `expectedParentId` conflict (with nothing appended), `idempotencyKey`
13
+ * deduplication, branching from any existing entry (not just the tip), and
14
+ * distinct linear appends sharing a key are not collapsed. Throws on the first
15
+ * violation; returns silently when the store conforms.
16
+ */
17
+ export async function assertSessionStoreConforms(store, options = {}) {
18
+ const sessionId = options.sessionId ?? "conformance";
19
+ const now = () => "2026-01-01T00:00:00.000Z";
20
+ const make = (id, parentId) => ({
21
+ id,
22
+ parentId,
23
+ sessionId,
24
+ timestamp: now(),
25
+ kind: "label",
26
+ label: id,
27
+ });
28
+ // 1. append + list round-trip.
29
+ const root = make("root");
30
+ await store.append(root);
31
+ const child = make("child", root.id);
32
+ await store.append(child);
33
+ assertIds(await store.list(sessionId), ["root", "child"], "append/list round-trip dropped entries");
34
+ // 2. duplicate entry id is rejected.
35
+ await reject(() => store.append(make("root")), /Duplicate session entry id: root/, "store must reject a duplicate entry id");
36
+ // 3. expectedParentId mismatch throws SessionAppendConflictError and writes nothing.
37
+ const before = (await store.list(sessionId)).length;
38
+ await reject(() => store.append(make("orphan"), { expectedParentId: "missing" }), (error) => isSessionAppendConflict(error) && error.conflict.expectedParentId === "missing", "store must throw SessionAppendConflictError when expectedParentId does not exist");
39
+ if ((await store.list(sessionId)).length !== before) {
40
+ throw new Error("A rejected append (missing parent) wrote entries; append must be atomic");
41
+ }
42
+ // 4. idempotencyKey dedup: an exact retry at the same position is rejected as a duplicate.
43
+ await store.append(make("idem-a"), { idempotencyKey: "k1" });
44
+ await reject(() => store.append(make("idem-dup"), { idempotencyKey: "k1" }), (error) => isSessionAppendConflict(error) && error.conflict.idempotencyDuplicate === true, "store must deduplicate an exact retry sharing an idempotencyKey");
45
+ // 5. branching from any existing entry (not just the tip) succeeds.
46
+ const branch = make("branch", root.id);
47
+ await store.append(branch, { expectedParentId: root.id });
48
+ if (!(await store.list(sessionId)).some((entry) => entry.id === "branch")) {
49
+ throw new Error("Branching from a non-tip existing entry was rejected; expectedParentId is existence-validation, not tip-CAS");
50
+ }
51
+ // 6. distinct linear appends sharing a run-level idempotencyKey are NOT collapsed.
52
+ const linearA = make("linear-a");
53
+ await store.append(linearA, { idempotencyKey: "run-1" });
54
+ const linearB = make("linear-b", linearA.id);
55
+ await store.append(linearB, { idempotencyKey: "run-1", expectedParentId: linearA.id });
56
+ if (!(await store.list(sessionId)).some((entry) => entry.id === "linear-b")) {
57
+ throw new Error("Distinct linear appends sharing an idempotencyKey were collapsed; only same-position retries dedup");
58
+ }
59
+ if (options.exerciseReadBranchPath && typeof store.readBranchPath === "function") {
60
+ const chain = await store.readBranchPath({ sessionId, leafId: branch.id });
61
+ const ids = chain.items.map((entry) => entry.id);
62
+ if (ids[0] !== "root" || ids[ids.length - 1] !== "branch") {
63
+ throw new Error(`readBranchPath must return the ancestor chain root→leaf in order; got ${JSON.stringify(ids)}`);
64
+ }
65
+ }
66
+ }
67
+ function assertIds(entries, expected, message) {
68
+ const actual = entries.map((entry) => entry.id);
69
+ if (actual.length !== expected.length || expected.some((id, i) => actual[i] !== id)) {
70
+ throw new Error(`${message}; expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
71
+ }
72
+ }
73
+ async function reject(fn, match, message) {
74
+ let threw = false;
75
+ try {
76
+ await fn();
77
+ }
78
+ catch (error) {
79
+ threw = true;
80
+ if (match instanceof RegExp) {
81
+ if (!match.test(error.message ?? String(error))) {
82
+ throw new Error(`${message}; error did not match ${match}: ${String(error)}`);
83
+ }
84
+ }
85
+ else if (!match(error)) {
86
+ throw new Error(`${message}; error did not satisfy predicate: ${String(error)}`);
87
+ }
88
+ }
89
+ if (!threw)
90
+ throw new Error(message);
91
+ }
92
+ //# sourceMappingURL=session-store-conformance.js.map
@@ -0,0 +1,39 @@
1
+ import type { AgentEvent, JsonObject, ToolCallContent, ToolDefinition, ToolExecutionContext, ToolRegistry, ToolResult } from "../contracts.js";
2
+ import type { PermissionPolicy } from "../security.js";
3
+ import { type ToolFilterInput, type ToolValidator } from "../tools.js";
4
+ export interface ToolDispatchProbeOptions {
5
+ readonly call: ToolCallContent;
6
+ readonly registry: ToolRegistry;
7
+ readonly context?: Partial<ToolExecutionContext>;
8
+ readonly filter?: ToolFilterInput;
9
+ readonly permission?: PermissionPolicy;
10
+ readonly validate?: ToolValidator;
11
+ readonly secrets?: readonly (string | undefined)[];
12
+ }
13
+ export interface ToolConformanceOptions {
14
+ /** A tool that will be registered and used as the success-path target. */
15
+ readonly tool: ToolDefinition;
16
+ /** Valid arguments object for the success-path probe. */
17
+ readonly validArgs: JsonObject;
18
+ /** Optional permission policy to apply (defaults to allow-all). */
19
+ readonly permission?: PermissionPolicy;
20
+ /** Optional validator to apply. */
21
+ readonly validate?: ToolValidator;
22
+ /** Optional filter applied to every probe (e.g. a deny list under test). */
23
+ readonly filter?: ToolFilterInput;
24
+ readonly secrets?: readonly (string | undefined)[];
25
+ }
26
+ /**
27
+ * Assert the full tool-dispatch contract against a fresh registry containing
28
+ * `options.tool`: unknown tools, denied tools, non-object arguments,
29
+ * permission denials, and validator failures all block with the canonical
30
+ * reason and never emit `tool_execution_started`; a valid call emits
31
+ * `tool_execution_started` and returns a result without an error. Throws on
32
+ * the first violation.
33
+ */
34
+ export declare function assertToolDispatchConforms(registry: ToolRegistry, options: ToolConformanceOptions): Promise<void>;
35
+ export declare function assertToolBlocked(probe: ToolDispatchProbeOptions, expectedReason: string): Promise<void>;
36
+ export declare function dispatchAndCollect(probe: ToolDispatchProbeOptions): Promise<{
37
+ result: ToolResult;
38
+ events: AgentEvent[];
39
+ }>;
@@ -0,0 +1,79 @@
1
+ // ponytail: dependency-free conformance helper for the tool-dispatch contract.
2
+ // Hosts configuring a ToolRegistry with allow/deny filters, permission policies,
3
+ // and validators call this once to assert the blocked-reason matrix
4
+ // (unknown_tool / tool_denied / invalid_arguments / permission_denied /
5
+ // validation_failed) and the success path. Mirrors the assertions in
6
+ // src/__tests__/tools.test.ts so hosts do not re-derive them. Throws plain
7
+ // Error; no test runner, no network. Execution is observed via the
8
+ // tool_execution_started / tool_execution_blocked events the runtime emits,
9
+ // not by mutating the caller's tool.
10
+ import { dispatchToolCall } from "../tools.js";
11
+ const denyAllPermission = { check: () => ({ allowed: false, reason: "denied" }) };
12
+ const alwaysInvalidValidator = () => "invalid";
13
+ /**
14
+ * Assert the full tool-dispatch contract against a fresh registry containing
15
+ * `options.tool`: unknown tools, denied tools, non-object arguments,
16
+ * permission denials, and validator failures all block with the canonical
17
+ * reason and never emit `tool_execution_started`; a valid call emits
18
+ * `tool_execution_started` and returns a result without an error. Throws on
19
+ * the first violation.
20
+ */
21
+ export async function assertToolDispatchConforms(registry, options) {
22
+ registry.register(options.tool);
23
+ const baseContext = { sessionId: "conformance", runId: "r", toolCallId: "call" };
24
+ const call = (name, args) => ({ type: "tool_call", id: "call", name, arguments: args });
25
+ const shared = { registry, secrets: options.secrets };
26
+ // 1. unknown tool → blocked "unknown_tool".
27
+ await assertToolBlocked({ call: call("does-not-exist", {}), context: baseContext, ...shared, ...pickPolicy(options) }, "unknown_tool");
28
+ // 2. denied tool (filter) → blocked "tool_denied".
29
+ await assertToolBlocked({ call: call(options.tool.name, options.validArgs), context: baseContext, ...shared, ...pickPolicy(options), filter: { deny: [options.tool.name] } }, "tool_denied");
30
+ // 3. invalid (non-object) arguments → blocked "invalid_arguments".
31
+ await assertToolBlocked({ call: call(options.tool.name, "not-an-object"), context: baseContext, ...shared, ...pickPolicy(options) }, "invalid_arguments");
32
+ // 4. permission denial → blocked "permission_denied".
33
+ await assertToolBlocked({ call: call(options.tool.name, options.validArgs), context: baseContext, ...shared, ...pickPolicy(options), permission: denyAllPermission }, "permission_denied");
34
+ // 5. validator failure → blocked "validation_failed".
35
+ await assertToolBlocked({ call: call(options.tool.name, options.validArgs), context: baseContext, ...shared, ...pickPolicy(options), validate: alwaysInvalidValidator }, "validation_failed");
36
+ // 6. valid call → executes (tool_execution_started), no error, no blocked event.
37
+ const probe = await dispatchAndCollect({ call: call(options.tool.name, options.validArgs), context: baseContext, ...shared, ...pickPolicy(options) });
38
+ if (probe.result.error)
39
+ throw new Error(`Valid tool call was not executed: ${probe.result.error.message}`);
40
+ if (!probe.events.some((event) => event.type === "tool_execution_started")) {
41
+ throw new Error("Valid tool call did not emit tool_execution_started");
42
+ }
43
+ if (probe.events.some((event) => event.type === "tool_execution_blocked")) {
44
+ throw new Error("Valid tool call emitted a tool_execution_blocked event");
45
+ }
46
+ }
47
+ export async function assertToolBlocked(probe, expectedReason) {
48
+ const captured = await dispatchAndCollect(probe);
49
+ const blockedEvent = captured.events.find((event) => event.type === "tool_execution_blocked");
50
+ if (!blockedEvent)
51
+ throw new Error(`Expected tool_execution_blocked for "${expectedReason}", but no blocked event was emitted`);
52
+ if (blockedEvent.type === "tool_execution_blocked" && blockedEvent.reason !== expectedReason) {
53
+ throw new Error(`Blocked reason mismatch: expected ${expectedReason}, got ${blockedEvent.reason}`);
54
+ }
55
+ if (!captured.result.error)
56
+ throw new Error(`Blocked call for "${expectedReason}" carried no error in the result`);
57
+ if (captured.events.some((event) => event.type === "tool_execution_started")) {
58
+ throw new Error(`Tool emitted tool_execution_started despite being blocked for "${expectedReason}"; blocked calls must not execute`);
59
+ }
60
+ }
61
+ export async function dispatchAndCollect(probe) {
62
+ const events = [];
63
+ const context = { sessionId: "conformance", runId: "r", toolCallId: probe.call.id, ...probe.context };
64
+ const result = await dispatchToolCall({
65
+ call: probe.call,
66
+ registry: probe.registry,
67
+ context,
68
+ filter: probe.filter,
69
+ permission: probe.permission,
70
+ validate: probe.validate,
71
+ secrets: probe.secrets,
72
+ emit: (event) => { events.push(event); },
73
+ });
74
+ return { result, events };
75
+ }
76
+ function pickPolicy(options) {
77
+ return { permission: options.permission, validate: options.validate, filter: options.filter };
78
+ }
79
+ //# sourceMappingURL=tool-conformance.js.map
package/dist/tools.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { AgentEvent, ErrorInfo, JsonObject, ToolCallContent, ToolDefinition, ToolExecutionContext, ToolRegistry, ToolResult } from "./contracts.js";
1
+ import type { AgentEvent, ErrorInfo, JsonObject, OwnershipScope, RunLedger, ToolCallContent, ToolDefinition, ToolExecutionContext, ToolRegistry, ToolResult } from "./contracts.js";
2
2
  import type { MiddlewareRegistry } from "./middleware.js";
3
3
  import { type SecretRedactor } from "./redaction.js";
4
+ import { type DuplicateRegistrationOptions } from "./registry-options.js";
4
5
  import { type PermissionPolicy } from "./security.js";
5
6
  export interface ToolFilter {
6
7
  readonly allow?: readonly string[];
@@ -19,7 +20,11 @@ export interface DispatchToolCallOptions {
19
20
  readonly secrets?: readonly (string | undefined)[];
20
21
  readonly permission?: PermissionPolicy;
21
22
  readonly redactor?: SecretRedactor;
23
+ readonly ledger?: RunLedger;
24
+ readonly ownership?: OwnershipScope;
22
25
  }
23
- export declare function createToolRegistry(tools?: readonly ToolDefinition[]): ToolRegistry;
26
+ export interface ToolRegistryOptions extends DuplicateRegistrationOptions {
27
+ }
28
+ export declare function createToolRegistry(tools?: readonly ToolDefinition[], options?: ToolRegistryOptions): ToolRegistry;
24
29
  export declare function filterTools(tools: readonly ToolDefinition[], filter?: ToolFilterInput): readonly ToolDefinition[];
25
30
  export declare function dispatchToolCall(options: DispatchToolCallOptions): Promise<ToolResult>;
package/dist/tools.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { isJsonObject } from "./config.js";
2
- import { errorToErrorInfo, redactSecrets } from "./redaction.js";
2
+ import { errorToErrorInfo, redactRunLedgerRecord, redactSecrets } from "./redaction.js";
3
+ import { assertCanRegister } from "./registry-options.js";
3
4
  import { assertPermission } from "./security.js";
4
- export function createToolRegistry(tools = []) {
5
+ export function createToolRegistry(tools = [], options = {}) {
5
6
  const byName = new Map();
6
7
  const registry = {
7
8
  register(tool) {
9
+ assertCanRegister(byName, tool.name, "tool", tool.name, options.duplicate);
8
10
  byName.set(tool.name, tool);
9
11
  },
10
12
  get(name) {
@@ -32,12 +34,13 @@ export function filterTools(tools, filter) {
32
34
  }
33
35
  export async function dispatchToolCall(options) {
34
36
  const secrets = options.secrets ?? [];
35
- const precheck = await checkCall(options.call, options);
37
+ const startedAt = new Date().toISOString();
38
+ const precheck = await checkCall(options.call, options, startedAt);
36
39
  if (precheck)
37
40
  return precheck;
38
41
  const mediatedCall = await (options.middleware?.run("tool_call", options.call) ?? options.call);
39
42
  const tool = options.registry.get(mediatedCall.name);
40
- const postcheck = await checkCall(mediatedCall, options);
43
+ const postcheck = await checkCall(mediatedCall, options, startedAt);
41
44
  if (postcheck)
42
45
  return postcheck;
43
46
  const context = {
@@ -54,45 +57,55 @@ export async function dispatchToolCall(options) {
54
57
  progress,
55
58
  metadata,
56
59
  });
60
+ await appendToolCallRecord(options, "started", mediatedCall, startedAt, {
61
+ progress,
62
+ progressMetadata: metadata,
63
+ progressAt: new Date().toISOString(),
64
+ });
57
65
  },
58
66
  };
59
67
  try {
60
68
  await assertPermission(options.permission, { kind: "tool", action: "execute", target: mediatedCall.name, metadata: options.context.metadata });
61
69
  }
62
70
  catch (error) {
63
- return blocked(mediatedCall, context, "permission_denied", errorToErrorInfo(error, secrets), options.emit);
71
+ return blocked(mediatedCall, context, "permission_denied", errorToErrorInfo(error, secrets), options, startedAt);
64
72
  }
65
73
  const validation = await options.validate?.(tool, mediatedCall.arguments, context);
66
74
  if (validation)
67
- return blocked(mediatedCall, context, "validation_failed", toErrorInfo(validation, secrets), options.emit);
75
+ return blocked(mediatedCall, context, "validation_failed", toErrorInfo(validation, secrets), options, startedAt);
68
76
  await options.emit?.({ type: "tool_execution_started", sessionId: context.sessionId, runId: context.runId, call: mediatedCall });
77
+ await appendToolCallRecord(options, "started", mediatedCall, startedAt, {});
69
78
  try {
70
79
  const raw = await tool.execute(mediatedCall.arguments, context);
71
80
  const mediatedResult = await (options.middleware?.run("tool_result", raw) ?? raw);
72
81
  const result = options.redactor?.redact(mediatedResult) ?? mediatedResult;
82
+ const finishedAt = new Date().toISOString();
73
83
  await options.emit?.({ type: "tool_execution_finished", sessionId: context.sessionId, runId: context.runId, result });
84
+ await appendToolCallRecord(options, "finished", mediatedCall, startedAt, { finishedAt, result });
74
85
  return result;
75
86
  }
76
87
  catch (error) {
77
88
  const info = errorToErrorInfo(error, secrets);
78
89
  const result = { toolCallId: mediatedCall.id, name: mediatedCall.name, error: info };
90
+ const finishedAt = new Date().toISOString();
79
91
  await options.emit?.({ type: "tool_execution_error", sessionId: context.sessionId, runId: context.runId, call: mediatedCall, error: info });
92
+ await appendToolCallRecord(options, "error", mediatedCall, startedAt, { finishedAt, result });
80
93
  return result;
81
94
  }
82
95
  }
83
- async function checkCall(call, options) {
96
+ async function checkCall(call, options, startedAt) {
84
97
  const context = options.context;
85
98
  const tool = options.registry.get(call.name);
86
99
  if (!tool)
87
- return blocked(call, context, "unknown_tool", { message: `Unknown tool: ${call.name}` }, options.emit);
100
+ return blocked(call, context, "unknown_tool", { message: `Unknown tool: ${call.name}` }, options, startedAt);
88
101
  if (filterTools([tool], options.filter).length === 0)
89
- return blocked(call, context, "tool_denied", { message: `Tool denied: ${call.name}` }, options.emit);
102
+ return blocked(call, context, "tool_denied", { message: `Tool denied: ${call.name}` }, options, startedAt);
90
103
  if (!isJsonObject(call.arguments))
91
- return blocked(call, context, "invalid_arguments", { message: "Tool arguments must be a JSON object" }, options.emit);
104
+ return blocked(call, context, "invalid_arguments", { message: "Tool arguments must be a JSON object" }, options, startedAt);
92
105
  return undefined;
93
106
  }
94
- async function blocked(call, context, reason, error, emit) {
95
- await emit?.({
107
+ async function blocked(call, context, reason, error, options, startedAt) {
108
+ await options.emit?.({
96
109
  type: "tool_execution_blocked",
97
110
  sessionId: context.sessionId,
98
111
  runId: context.runId,
@@ -101,9 +114,33 @@ async function blocked(call, context, reason, error, emit) {
101
114
  reason,
102
115
  error,
103
116
  });
104
- return { toolCallId: call.id, name: call.name, error };
117
+ const finishedAt = new Date().toISOString();
118
+ const result = { toolCallId: call.id, name: call.name, error };
119
+ await appendToolCallRecord(options, "blocked", call, startedAt, { reason, finishedAt, result });
120
+ return result;
105
121
  }
106
122
  function toErrorInfo(value, secrets) {
107
123
  return typeof value === "string" ? errorToErrorInfo(value, secrets) : redactSecrets(value, secrets);
108
124
  }
125
+ function randomId(prefix) {
126
+ return `${prefix}_${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
127
+ }
128
+ function appendToolCallRecord(options, status, call, startedAt, fields) {
129
+ if (!options.ledger)
130
+ return undefined;
131
+ const record = {
132
+ id: randomId("toolcall"),
133
+ sessionId: options.context.sessionId,
134
+ runId: options.context.runId,
135
+ toolCallId: call.id,
136
+ name: call.name,
137
+ arguments: call.arguments,
138
+ status,
139
+ startedAt,
140
+ redacted: Boolean(options.redactor),
141
+ ...options.ownership,
142
+ ...fields,
143
+ };
144
+ return options.ledger.appendToolCall(redactRunLedgerRecord(record, options.redactor));
145
+ }
109
146
  //# sourceMappingURL=tools.js.map
@@ -0,0 +1,251 @@
1
+ # Agent definitions
2
+
3
+ ## What it does
4
+
5
+ An agent definition declares what an agent needs — model, tools, skills, context, system prompt, instructions, loop — without owning how those are loaded or executed. Two helpers turn a definition into a runnable `Agent`:
6
+
7
+ - `resolveAgentDefinition(def, context)` (re-exported from `@arnilo/prism`): resolves a declarative `AgentDefinition` against the registries the host passes in. Missing dependencies fail closed before any provider turn.
8
+ - `resolveAgentBundle(bundle, options)` (Node subpath `@arnilo/prism/node/agent-definitions`): turns a discovered app-config agent bundle envelope into an `Agent` by building union skill/tool registries across three scopes, appending up to three prompt layers, and delegating to `resolveAgentDefinition`.
9
+
10
+ A third helper, `discoverAgentBundles(options)` (same Node subpath), scans an app-controlled `configRoot` for agent bundle envelopes without parsing or importing any file content; resolution happens later in `resolveAgentBundle`.
11
+
12
+ `AgentDefinition` is the same shape registered programmatically via `ExtensionAPI.registerAgent()` (see [Extensions](extensions.md)) — a definition is data, registration is inert, activation is run-owned.
13
+
14
+ ## When to use it
15
+
16
+ Use `resolveAgentDefinition` when an app already holds a `AgentDefinition` (from an extension, a manifest, or hand-written config) and wants to turn it into an `Agent` against its registries — without wiring every field by hand.
17
+
18
+ Use `discoverAgentBundles` + `resolveAgentBundle` when a host app keeps per-agent bundles on disk under an app-controlled config root (for example `.clay/extensions/prism/agents/<agentName>/AGENT.md`) and wants to honor them as first-class agents. The bundle layout is host-owned: Prism never picks the config root, never touches the user's home directory, and never auto-runs resolution — the host calls `discoverAgentBundles` and then `resolveAgentBundle` explicitly.
19
+
20
+ Do not use the bundle loader to discover providers — provider/model packages stay config/package-driven (Phase 24; see [Provider packages](provider-packages.md)). Do not use it to auto-activate undeclared tools or skills: omitted `tools` / `skills` means no active capabilities by default; named bundle entries are explicit activation, and runtime skill selection can narrow further with `RunOptions.activeSkills`.
21
+
22
+ ## Inputs / request
23
+
24
+ ### `AgentDefinition` (contract, `@arnilo/prism`)
25
+
26
+ | Field | Meaning |
27
+ | --- | --- |
28
+ | `name` | Required agent name. |
29
+ | `description?` | Optional description. |
30
+ | `model?` | `ModelConfig` object, or a `"<provider>/<model>"` string resolved through `registries.models`. |
31
+ | `tools?` | Tool names to activate from the active tool registry / `registries.tools`. Omitted means no active tools unless `activateAllCapabilities: true` is passed for migration. |
32
+ | `skills?` | Skill names resolved via `resolveActiveSkills()`; omitted means no active skills unless `activateAllCapabilities: true` is passed for migration. `toolNames` enforcement applies at activation. |
33
+ | `context?` | Context provider names from `registries.contextProviders`. |
34
+ | `systemPrompt?` | `SystemPromptConfig` layer (see [System prompts](system-prompts.md)). |
35
+ | `instructions?` | Base prompt text. |
36
+ | `loop?` | `AgentLoopStrategy` or `AgentLoopOptions` (see [Agent loops](agent-loops.md)). |
37
+ | `metadata?` | Free-form metadata. |
38
+ | `create?(config?)` | Optional escape hatch. When present, overrides declarative resolution: the helper builds a base `AgentConfig` from the declarative fields, calls `create(config)`, then merges `context.overrides`. |
39
+
40
+ ### `AgentDefinitionResolutionContext` (contract, `@arnilo/prism`)
41
+
42
+ All fields optional — the host controls scope by which registries it passes.
43
+
44
+ | Field | Meaning |
45
+ | --- | --- |
46
+ | `registries?` | `ContributionRegistries` carrying `models`, `providers`, `tools`, `contextProviders`, ... |
47
+ | `providerSource?` | `ProviderResolver` override. |
48
+ | `tools?` | `ToolRegistry` or `readonly ToolDefinition[]` scope override. |
49
+ | `skillsRegistry?` | `SkillRegistry` override. |
50
+ | `activateAllCapabilities?` | Migration-only `true`: omitted `tools`/`skills` activate every in-scope tool/skill. Default is fail-closed: omitted means none. |
51
+ | `overrides?` | `Partial<AgentConfig>` applied last, after declarative resolution. |
52
+
53
+ ### App-config bundle layout
54
+
55
+ `resolveAgentBundle` reads a three-scope layout rooted at an app-controlled `configRoot` (plus optional repo contributions from `discoverContributions`):
56
+
57
+ | Scope | Path | Default include flag |
58
+ | --- | --- | --- |
59
+ | App-global prompt | `<configRoot>/agents/SYSTEM.md` | `include.systemPrompt` (default `true`) |
60
+ | Per-agent definition + body prompt | `<configRoot>/agents/<agentName>/AGENT.md` | `include.agentPrompt` (default `true`) |
61
+ | Repo project prompt | `<workspaceRoot>/AGENTS.md` | `include.repoPrompt` (default `true`) |
62
+ | App-global skills | `<configRoot>/agents/skills/<name>/SKILL.md` | `include.globalSkills` (default `true`) |
63
+ | App-global tools | `<configRoot>/agents/tools/<name>/manifest.json` | `include.globalTools` (default `true`) |
64
+ | Per-agent skills | `<configRoot>/agents/<agentName>/skills/<name>/SKILL.md` | `include.agentSkills` (default `true`) |
65
+ | Per-agent tools | `<configRoot>/agents/<agentName>/tools/<name>/manifest.json` | `include.agentTools` (default `true`) |
66
+ | Repo skills | `<workspaceRoot>/.agents/skills/<name>/SKILL.md` | `include.repoSkills` (default `true`) |
67
+ | Repo tools | `<workspaceRoot>/.agents/tools/<name>/manifest.json` | `include.repoTools` (default `true`) |
68
+
69
+ `AGENT.md` frontmatter mirrors the declarative `AgentDefinition` fields above that fit YAML: `name`, `description`, `model` (a `"<provider>/<model>"` string), `tools` (list), `skills` (list), `context` (list), and `instructions` (frontmatter value or the markdown body as the per-agent prompt layer). `systemPrompt` and `loop` are intentionally deferred from frontmatter — they carry complex types not easily represented in YAML; pass them through `context.overrides` or a `create()` escape hatch.
70
+
71
+ ### `AgentBundle` / `DiscoverAgentBundlesOptions` (`@arnilo/prism/node/agent-definitions`)
72
+
73
+ `discoverAgentBundles({ configRoot, trust?, permission?, signal? })` returns one `AgentBundle` per subdirectory of `<configRoot>/agents/` that contains an `AGENT.md`. Each `AgentBundle` carries paths only — `name`, `path` (the `AGENT.md`), `configRoot`, an optional `systemPromptPath`, and the collected `globalSkills`/`globalTools`/`agentSkills`/`agentTools` path lists. No file content is parsed here.
74
+
75
+ ### `ResolveAgentBundleOptions` (`@arnilo/prism/node/agent-definitions`)
76
+
77
+ | Field | Meaning |
78
+ | --- | --- |
79
+ | `readFile?` | File reader. Defaults to `node:fs/promises.readFile`. |
80
+ | `workspaceRoot?` | Workspace root for the repo prompt and repo contributions. |
81
+ | `repoContributions?` | Repo-level contributions from `discoverContributions` (see [Contribution discovery](contribution-discovery.md)). |
82
+ | `registries?` | Host registries (`models`, `providers`, `contextProviders`, ...). |
83
+ | `providerSource?` / `tools?` / `skillsRegistry?` / `activateAllCapabilities?` | Forwarded to `AgentDefinitionResolutionContext`. `activateAllCapabilities` is migration-only; default omitted `tools`/`skills` activate none. |
84
+ | `overrides?` | `Partial<AgentConfig>` applied after resolution. |
85
+ | `trust?` | `TrustPolicy` gating the app-config root and workspace root **independently**. |
86
+ | `permission?` | `PermissionPolicy` asserting each prompt-file read. |
87
+ | `include?` | `AgentBundleScopeFlags` — every source defaults to `true`; set a flag to `false` to drop that scope's contributions for this resolution. |
88
+
89
+ ## Outputs / response / events
90
+
91
+ `resolveAgentDefinition()` and `resolveAgentBundle()` return an `Agent` (sync or `Promise<Agent>` when the `create()` escape hatch is async). The helpers never emit events; the returned `Agent`/session emits the normal `AgentEvent` stream when run.
92
+
93
+ Skill and tool contributions are merged as a **union** across the enabled scopes — there is no override on name collision. A duplicate skill or tool name across two enabled scopes throws `Duplicate skill/tool name across scopes: <name> (found in <scope> and <other>)` so the conflict is surfaced instead of silently masked. Disable a scope via its `include` flag to resolve a collision deliberately.
94
+
95
+ Prompt layers are appended (never replace) in this fixed order, reusing `composeSystemPrompt`'s `source` rank (`user` → `package` → `app` → `run`):
96
+
97
+ 1. `<configRoot>/agents/SYSTEM.md` — app-global, `source: "user"`.
98
+ 2. `<configRoot>/agents/<agentName>/AGENT.md` body — per-agent, `source: "package"`.
99
+ 3. `<workspaceRoot>/AGENTS.md` — repo project prompt, `source: "app"`.
100
+
101
+ The app-config root and the workspace root are trust-gated independently: an untrusted root contributes nothing for that layer (fail-closed, silent skip) while the other layers still load. `RunOptions.systemPrompt` (`source: "run"`) is appended last at run time and still wins. Missing files are skipped silently.
102
+
103
+ ## Request/response example
104
+
105
+ A discovered bundle at `<configRoot>/agents/coding/AGENT.md`:
106
+
107
+ ```yaml
108
+ ---
109
+ name: coding
110
+ model: openai/gpt-4o
111
+ tools: [read, echo]
112
+ skills: [format]
113
+ context: [repo]
114
+ instructions: You are a careful coding agent.
115
+ ---
116
+ Prefer minimal diffs. Cite the file you changed.
117
+ ```
118
+
119
+ `discoverAgentBundles({ configRoot })` returns (paths only):
120
+
121
+ ```json
122
+ [
123
+ {
124
+ "name": "coding",
125
+ "path": "/app/agents/coding/AGENT.md",
126
+ "configRoot": "/app",
127
+ "systemPromptPath": "/app/agents/SYSTEM.md",
128
+ "globalSkills": ["/app/agents/skills/format/SKILL.md"],
129
+ "globalTools": [],
130
+ "agentSkills": [],
131
+ "agentTools": []
132
+ }
133
+ ]
134
+ ```
135
+
136
+ ## Implementation example
137
+
138
+ ```ts
139
+ import { type AgentBundle } from "@arnilo/prism/node/agent-definitions";
140
+ import { discoverAgentBundles, resolveAgentBundle } from "@arnilo/prism/node/agent-definitions";
141
+ import { discoverContributions } from "@arnilo/prism/node/contribution-discovery";
142
+ import { createPathTrustPolicy } from "@arnilo/prism/node/trust";
143
+ import { createContributionRegistries } from "@arnilo/prism";
144
+
145
+ // 1. App owns configRoot; never auto-discovered, never the user's home dir.
146
+ const configRoot = "/app/cfg";
147
+ const workspaceRoot = "/repo";
148
+ const trust = createPathTrustPolicy({ trustedRoots: [configRoot, workspaceRoot] });
149
+
150
+ // 2. Discover bundle envelopes (paths only — no parse, no import).
151
+ const [bundle] = await discoverAgentBundles({ configRoot, trust });
152
+ if (!bundle) throw new Error("no agent bundle found");
153
+
154
+ // 3. Repo .agents/ contributions are a separate scan (opt-in discovery).
155
+ const registries = createContributionRegistries();
156
+ const repoContributions = await discoverContributions({
157
+ kinds: ["skill", "tool"],
158
+ workspaceRoot,
159
+ trust,
160
+ });
161
+
162
+ // 4. Resolve the bundle into an Agent. Skills/tools union across scopes;
163
+ // duplicate names across scopes throw. Prompt layers append: SYSTEM.md →
164
+ // AGENT.md body → AGENTS.md. Drop the repo AGENTS.md layer here via flags.
165
+ const agent = await resolveAgentBundle(bundle as AgentBundle, {
166
+ workspaceRoot,
167
+ repoContributions,
168
+ registries,
169
+ trust,
170
+ include: { repoPrompt: false },
171
+ });
172
+
173
+ // 5. The host still owns activation.
174
+ await agent.createSession().run("Hi", { activeSkills: ["format"] });
175
+ ```
176
+
177
+ For a hand-held `AgentDefinition` without a bundle, `resolveAgentDefinition` is the lighter path. Capability lists are explicit: omit `tools`/`skills` to activate none.
178
+
179
+ ```ts
180
+ import { resolveAgentDefinition } from "@arnilo/prism";
181
+
182
+ const agent = resolveAgentDefinition(
183
+ { name: "doc", model: "openai/gpt-4o", tools: ["echo"], skills: ["brief"], instructions: "Be concise." },
184
+ { registries, providerSource, tools: myToolRegistry, skillsRegistry },
185
+ );
186
+
187
+ // Migration-only old behavior: omitted tools/skills means all in-scope tools/skills.
188
+ const legacy = resolveAgentDefinition(
189
+ { name: "legacy", model: "openai/gpt-4o" },
190
+ { registries, tools: myToolRegistry, skillsRegistry, activateAllCapabilities: true },
191
+ );
192
+ ```
193
+
194
+ ## Migration: explicit capability activation
195
+
196
+ Old Phase 37 behavior could treat an omitted `tools` list as “every scoped tool”; some hosts also expected all scoped skills to be available. Phase 38 changes the safe default: omitted `tools` and omitted `skills` mean no active capabilities.
197
+
198
+ Preferred migration:
199
+
200
+ ```ts
201
+ // Before: omitted tools could receive every scoped tool.
202
+ resolveAgentDefinition({ name: "doc", model: "openai/gpt-4o" }, context);
203
+
204
+ // After: list the capabilities this agent may use.
205
+ resolveAgentDefinition(
206
+ { name: "doc", model: "openai/gpt-4o", tools: ["read"], skills: ["brief"] },
207
+ context,
208
+ );
209
+ ```
210
+
211
+ Temporary compatibility shim:
212
+
213
+ ```ts
214
+ resolveAgentDefinition(
215
+ { name: "legacy", model: "openai/gpt-4o" },
216
+ { ...context, activateAllCapabilities: true },
217
+ );
218
+ ```
219
+
220
+ Use `activateAllCapabilities: true` only while migrating old configs. It intentionally scans/list-activates every in-scope tool/skill. New configs should list names and use strict contribution registries (`createContributionRegistries({ duplicate: "error" })`) so a third-party package cannot silently shadow a capability name.
221
+
222
+ ## Extension and configuration notes
223
+
224
+ - `ExtensionAPI.registerAgent(agent)` contributes an inert `AgentDefinition` programmatically; its `create()` (if present) is only invoked when the host runs it through `resolveAgentDefinition`. See [Extensions](extensions.md).
225
+ - Bundle resolution is config over code: every seam lives on `AgentDefinition`, `AgentDefinitionResolutionContext`, or `ResolveAgentBundleOptions`. `systemPrompt` and `loop` are passed via `context.overrides` / `create()` rather than frontmatter.
226
+ - Migration note: before Phase 38, a definition that omitted `tools` but had a tool scope could receive every scoped tool. Now omitted `tools`/`skills` activates none. Add explicit names to `tools` / `skills`; use `activateAllCapabilities: true` only while migrating old configs.
227
+ - `parseAgentFile(text, path)` (re-exported from `@arnilo/prism`) is the stdlib-only frontmatter parser for `AGENT.md`. `parseContextFile` and `parseToolFile` parse colocated `CONTEXT.md` / tool descriptors inside the Node subpath.
228
+ - Repo contributions (`<workspaceRoot>/.agents/{skills,tools}/`) are scanned by `discoverContributions` and passed via `repoContributions`. Repo `.agents/` is preserved as a shared contribution surface across every agent that operates on the same repository; multiple agents from different apps can work the same repo, and all share its repo-level skills.
229
+
230
+ ## Security and performance notes
231
+
232
+ - **App owns `configRoot`**: Prism never picks the config root, never defaults to the user's home directory, and never auto-runs discovery or resolution. The host calls `discoverAgentBundles` / `resolveAgentBundle` explicitly. `--agents-config <path>` on the `prism` CLI is the explicit opt-in.
233
+ - **All layers optional**: every prompt and contribution scope is independently togglable via `AgentBundleScopeFlags` (all default `true`); region can be enabled per agent or globally by the host.
234
+ - **Explicit capability activation**: omitted `AgentDefinition.tools` / `skills` activates no tools or skills by default, even when registries are in scope. Named dependencies resolve fail-closed; `activateAllCapabilities: true` is the only legacy all-capabilities opt-in.
235
+ - **Duplicate names error, not override**: skills/tools union across scopes throw on collision so a name shadow can never silently change behavior.
236
+ - **Independent trust gating**: `resolveAgentBundle` trust-gates the app-config root (`SYSTEM.md`) and the workspace root (`AGENTS.md`) independently via `createPathTrustPolicy` + `isPathInsideReal`; symlink escapes are excluded, untrusted roots contribute nothing (fail-closed). See [Security/auth/trust](settings-auth-trust-security.md).
237
+ - **No execution on discovery**: `discoverAgentBundles` returns paths only; `resolveAgentBundle` reads text only — it never `import()`s, `require()`s, or `eval()`s any contribution file. Tool descriptors parsed from `manifest.json` are descriptor-only `ToolDefinition` objects whose `execute()` throws `Discovered tool <name> requires host execution` if invoked. The host lifts them into live tools itself.
238
+ - **Secrets**: loaded prompt/skill text is caller- and host-supplied content subject to `redactProviderRequest` like any system instruction. Do not put secrets in `AGENT.md` / `SYSTEM.md` / `SKILL.md`, settings, manifests, or docs examples.
239
+ - **Performance**: `discoverAgentBundles` is one `readdir` per level; `resolveAgentBundle` reads each prompt file at most once per call. There is no cross-call cache — resolution is one-shot (memoize in the host if the same bundle is resolved repeatedly).
240
+
241
+ ## Related APIs
242
+
243
+ - [Agent/session runtime](agent-session-runtime.md): `createAgent` / `createAgentSession`, the runtime `resolveAgentDefinition` builds onto.
244
+ - [System prompts](system-prompts.md): `composeSystemPrompt` source ranks and the `AGENT.md` body / `SYSTEM.md` / `AGENTS.md` prompt layering reused by `resolveAgentBundle`.
245
+ - [Contribution discovery (workspace)](contribution-discovery.md): `discoverContributions` for repo `.agents/` contributions passed as `repoContributions`.
246
+ - [Context and skills](context-and-skills.md): `resolveActiveSkills` and `RunOptions.activeSkills` activation that consumes discovered skills.
247
+ - [Tools](tools.md): `ToolDefinition` / `(toolNames)` enforcement and host-owned tool execution.
248
+ - [Agent loops](agent-loops.md): `resolveLoop` and loop strategies passed via `loop` / `context.overrides`.
249
+ - [Extensions](extensions.md): `registerAgent()` programmatic registration of inert `AgentDefinition` values.
250
+ - [CLI/RPC](cli-rpc.md): the `--agents-config <path>` flag.
251
+ - [Security/auth/trust](settings-auth-trust-security.md): `createPathTrustPolicy`, `assertPermission`, and the trust model.