@arnilo/prism 0.0.3 → 0.0.4

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 (99) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +32 -20
  3. package/dist/agent-loops.d.ts +8 -1
  4. package/dist/agent-loops.js +57 -11
  5. package/dist/agents.js +70 -17
  6. package/dist/checkpoints.d.ts +11 -0
  7. package/dist/checkpoints.js +144 -0
  8. package/dist/compaction.js +9 -1
  9. package/dist/content.d.ts +102 -0
  10. package/dist/content.js +410 -0
  11. package/dist/contracts.d.ts +142 -2
  12. package/dist/event-multiplexer.d.ts +23 -0
  13. package/dist/event-multiplexer.js +136 -0
  14. package/dist/execution-policy.d.ts +28 -0
  15. package/dist/execution-policy.js +24 -0
  16. package/dist/index.d.ts +17 -5
  17. package/dist/index.js +11 -4
  18. package/dist/input.js +11 -1
  19. package/dist/leases.d.ts +8 -0
  20. package/dist/leases.js +111 -0
  21. package/dist/node/agent-definitions.js +3 -5
  22. package/dist/node/config.d.ts +1 -0
  23. package/dist/node/config.js +5 -3
  24. package/dist/node/contribution-discovery.js +5 -8
  25. package/dist/node/session-store-jsonl.js +8 -5
  26. package/dist/node/settings.js +2 -2
  27. package/dist/node/trust.js +2 -4
  28. package/dist/observability.d.ts +3 -0
  29. package/dist/observability.js +18 -0
  30. package/dist/providers/media.d.ts +42 -0
  31. package/dist/providers/media.js +116 -0
  32. package/dist/providers/openai-compatible.js +18 -119
  33. package/dist/providers/openai-primitives.d.ts +9 -0
  34. package/dist/providers/openai-primitives.js +129 -0
  35. package/dist/providers/transport.d.ts +40 -0
  36. package/dist/providers/transport.js +221 -0
  37. package/dist/redaction.js +40 -13
  38. package/dist/resources.d.ts +5 -0
  39. package/dist/resources.js +4 -0
  40. package/dist/structured-output.d.ts +11 -0
  41. package/dist/structured-output.js +59 -0
  42. package/dist/testing/persistence-schema.d.ts +102 -0
  43. package/dist/testing/persistence-schema.js +457 -0
  44. package/dist/testing/provider-conformance.js +10 -1
  45. package/dist/testing/run-ledger-conformance.d.ts +33 -0
  46. package/dist/testing/run-ledger-conformance.js +172 -0
  47. package/dist/testing/session-store-conformance.d.ts +16 -0
  48. package/dist/testing/session-store-conformance.js +73 -0
  49. package/dist/tools.d.ts +17 -0
  50. package/dist/tools.js +29 -2
  51. package/docs/agent-events.md +13 -4
  52. package/docs/agent-loops.md +10 -4
  53. package/docs/agent-session-runtime.md +1 -0
  54. package/docs/cli-rpc.md +3 -0
  55. package/docs/coding-agent-tools.md +41 -7
  56. package/docs/coding-security.md +84 -0
  57. package/docs/credential-storage.md +177 -0
  58. package/docs/credentials-and-redaction.md +2 -1
  59. package/docs/database-persistence.md +44 -2
  60. package/docs/host-security.md +15 -1
  61. package/docs/index.md +28 -12
  62. package/docs/input-and-prompt-assembly.md +6 -5
  63. package/docs/mcp-tools.md +139 -0
  64. package/docs/middleware-hooks.md +2 -0
  65. package/docs/migration.md +21 -28
  66. package/docs/model-registry.md +5 -3
  67. package/docs/multimodal-content.md +148 -0
  68. package/docs/observability.md +163 -0
  69. package/docs/performance.md +40 -1
  70. package/docs/persistence-credentials-multimodality-primitives.md +303 -0
  71. package/docs/postgres-persistence.md +141 -0
  72. package/docs/provider-conformance.md +17 -0
  73. package/docs/provider-layer.md +1 -1
  74. package/docs/provider-primitives.md +281 -0
  75. package/docs/providers/kimi.md +1 -0
  76. package/docs/providers/neuralwatt.md +1 -0
  77. package/docs/providers/openai-compatible.md +2 -1
  78. package/docs/providers/openai.md +8 -1
  79. package/docs/providers/opencode-go.md +1 -0
  80. package/docs/providers/openrouter.md +1 -0
  81. package/docs/providers/zai.md +1 -0
  82. package/docs/public-contracts.md +9 -2
  83. package/docs/release-and-install.md +209 -25
  84. package/docs/resource-loading.md +14 -4
  85. package/docs/review-coverage-2026-07-14.md +260 -0
  86. package/docs/run-ledger-conformance.md +96 -0
  87. package/docs/runs-and-usage.md +2 -0
  88. package/docs/session-store-conformance.md +16 -0
  89. package/docs/session-stores-and-branching.md +1 -0
  90. package/docs/settings-auth-trust-security.md +2 -1
  91. package/docs/sqlite-persistence.md +122 -0
  92. package/docs/structured-output.md +9 -0
  93. package/docs/tool-conformance.md +1 -0
  94. package/docs/tool-execution-primitives.md +374 -0
  95. package/docs/tools.md +39 -1
  96. package/docs/workflow-orchestration-primitives.md +565 -0
  97. package/docs/workflow-tui-primitives.md +5 -0
  98. package/docs/workflows.md +219 -0
  99. package/package.json +33 -5
@@ -0,0 +1,172 @@
1
+ // ponytail: dependency-free conformance helper for the RunLedger adapter contract.
2
+ // Database-backed adapters call this once (or via runRunLedgerConformance factory)
3
+ // to assert durable run/event/tool/usage writes, per-run ordering, tenant
4
+ // isolation, and restart idempotency before shipping dialect-local SQL.
5
+ const NOW = "2026-01-01T00:00:00.000Z";
6
+ /**
7
+ * Assert that a `RunLedger` implementation satisfies the write contract:
8
+ * all record kinds round-trip via optional read callbacks, per-run event order
9
+ * is preserved, and tenant-scoped rows store `tenant_id` when
10
+ * `exerciseTenantIsolation` is enabled.
11
+ */
12
+ export async function assertRunLedgerConforms(fixture, options = {}) {
13
+ const sessionId = options.sessionId ?? "ledger-conformance";
14
+ const runId = options.runId ?? "run-conformance";
15
+ const tenantId = options.tenantId ?? "tenant-a";
16
+ const scope = {
17
+ tenantId,
18
+ accountId: options.accountId ?? "account-a",
19
+ userId: options.userId ?? "user-a",
20
+ };
21
+ const runStart = {
22
+ id: runId,
23
+ sessionId,
24
+ status: "running",
25
+ startedAt: NOW,
26
+ provider: "mock",
27
+ model: { provider: "mock", model: "demo" },
28
+ idempotencyKey: "run-key-1",
29
+ ...scope,
30
+ };
31
+ const runFinish = {
32
+ ...runStart,
33
+ status: "succeeded",
34
+ finishedAt: "2026-01-01T00:00:01.000Z",
35
+ };
36
+ const eventA = {
37
+ id: "event-a",
38
+ sessionId,
39
+ runId,
40
+ type: "agent_started",
41
+ timestamp: NOW,
42
+ event: { type: "agent_started", sessionId, runId },
43
+ redacted: false,
44
+ ...scope,
45
+ };
46
+ const eventB = {
47
+ id: "event-b",
48
+ sessionId,
49
+ runId,
50
+ type: "turn_started",
51
+ timestamp: "2026-01-01T00:00:00.500Z",
52
+ event: { type: "turn_started", sessionId, runId, turn: 1 },
53
+ redacted: false,
54
+ ...scope,
55
+ };
56
+ const tool = {
57
+ id: "tool-row-1",
58
+ sessionId,
59
+ runId,
60
+ toolCallId: "call-1",
61
+ name: "echo",
62
+ arguments: { msg: "hi" },
63
+ status: "finished",
64
+ result: { toolCallId: "call-1", name: "echo", value: "hi" },
65
+ startedAt: NOW,
66
+ finishedAt: "2026-01-01T00:00:00.800Z",
67
+ redacted: false,
68
+ ...scope,
69
+ };
70
+ const usage = {
71
+ id: "usage-1",
72
+ sessionId,
73
+ runId,
74
+ usage: { inputTokens: 3, outputTokens: 5, totalTokens: 8 },
75
+ recordedAt: "2026-01-01T00:00:01.000Z",
76
+ ...scope,
77
+ };
78
+ await fixture.ledger.appendRun(runStart);
79
+ await fixture.ledger.appendEvent(eventA);
80
+ await fixture.ledger.appendEvent(eventB);
81
+ await fixture.ledger.appendToolCall(tool);
82
+ await fixture.ledger.appendUsage(usage);
83
+ await fixture.ledger.appendRun(runFinish);
84
+ if (fixture.readRuns) {
85
+ const runs = await fixture.readRuns();
86
+ const snapshots = runs.filter((row) => row.id === runId);
87
+ if (!snapshots.some((row) => row.status === "succeeded")) {
88
+ throw new Error("RunLedger must persist the terminal RunRecord");
89
+ }
90
+ const succeeded = snapshots.find((row) => row.status === "succeeded");
91
+ if (succeeded.startedAt !== runStart.startedAt) {
92
+ throw new Error("RunLedger must preserve startedAt on the terminal RunRecord");
93
+ }
94
+ if (snapshots.length > 1 && !snapshots.some((row) => row.status === "running")) {
95
+ throw new Error("RunLedger must persist the running RunRecord when multiple snapshots are stored");
96
+ }
97
+ }
98
+ if (fixture.readEvents) {
99
+ const events = await fixture.readEvents();
100
+ const forRun = events.filter((row) => row.runId === runId);
101
+ const ids = forRun.map((row) => row.id);
102
+ const aIndex = ids.indexOf("event-a");
103
+ const bIndex = ids.indexOf("event-b");
104
+ if (aIndex < 0 || bIndex < 0)
105
+ throw new Error("RunLedger dropped appended AgentEventRecord rows");
106
+ if (aIndex > bIndex)
107
+ throw new Error("RunLedger must preserve per-run event append order");
108
+ }
109
+ if (fixture.readToolCalls) {
110
+ const toolCalls = await fixture.readToolCalls();
111
+ if (!toolCalls.some((row) => row.toolCallId === "call-1")) {
112
+ throw new Error("RunLedger must persist ToolCallRecord rows");
113
+ }
114
+ }
115
+ if (fixture.readUsage) {
116
+ const usageRows = await fixture.readUsage();
117
+ if (!usageRows.some((row) => row.id === "usage-1")) {
118
+ throw new Error("RunLedger must persist UsageRecord rows");
119
+ }
120
+ }
121
+ if (options.exerciseTenantIsolation && fixture.readRuns) {
122
+ const otherRun = {
123
+ id: "run-tenant-b",
124
+ sessionId: `${sessionId}-tenant-b`,
125
+ status: "running",
126
+ startedAt: NOW,
127
+ provider: "mock",
128
+ tenantId: "tenant-b",
129
+ accountId: "account-b",
130
+ userId: "user-b",
131
+ };
132
+ await fixture.ledger.appendRun(otherRun);
133
+ const runs = await fixture.readRuns();
134
+ const stored = runs.find((row) => row.id === "run-tenant-b");
135
+ if (!stored || stored.tenantId !== "tenant-b") {
136
+ throw new Error("RunLedger must persist tenant_id on records for tenant-scoped isolation");
137
+ }
138
+ const scoped = runs.filter((row) => row.tenantId === tenantId);
139
+ if (!scoped.some((row) => row.id === runId)) {
140
+ throw new Error("RunLedger read path must return tenant-a rows when queried for conformance session");
141
+ }
142
+ }
143
+ }
144
+ /**
145
+ * Factory-based conformance entry point for durable adapters. Invokes
146
+ * `assertRunLedgerConforms` against a fresh fixture and optionally reopens via
147
+ * the same factory to assert writes survive process/database reopen.
148
+ */
149
+ export async function runRunLedgerConformance(factory, options = {}) {
150
+ const first = await factory();
151
+ await assertRunLedgerConforms(first, options);
152
+ if (!options.exerciseReopen)
153
+ return;
154
+ const reopened = await factory();
155
+ if (!reopened.readRuns && !reopened.readEvents) {
156
+ throw new Error("exerciseReopen requires readRuns or readEvents on the fixture");
157
+ }
158
+ if (reopened.readRuns) {
159
+ const runs = await reopened.readRuns();
160
+ const runId = options.runId ?? "run-conformance";
161
+ if (!runs.some((row) => row.id === runId)) {
162
+ throw new Error("RunLedger rows did not survive adapter reopen");
163
+ }
164
+ }
165
+ if (reopened.readEvents) {
166
+ const events = await reopened.readEvents();
167
+ if (!events.some((row) => row.id === "event-a")) {
168
+ throw new Error("RunLedger events did not survive adapter reopen");
169
+ }
170
+ }
171
+ }
172
+ //# sourceMappingURL=run-ledger-conformance.js.map
@@ -2,13 +2,23 @@ import type { SessionStore } from "../contracts.js";
2
2
  export interface SessionStoreConformanceOptions {
3
3
  /** Stable session id used for the conformance run; defaults to "conformance". */
4
4
  readonly sessionId?: string;
5
+ /** Secondary session id for branch-isolation probes; defaults to "conformance-other". */
6
+ readonly otherSessionId?: string;
5
7
  /**
6
8
  * When true, also exercises the optional `readBranchPath` branch-reader path
7
9
  * and asserts it returns the ancestor chain in root-to-leaf order. Skipped
8
10
  * when the store does not implement `readBranchPath`.
9
11
  */
10
12
  readonly exerciseReadBranchPath?: boolean;
13
+ /** When true, appends concurrent children of the same parent (fork allowed). */
14
+ readonly exerciseConcurrentParentAppend?: boolean;
15
+ /**
16
+ * When true, the factory is invoked again after writes to assert durable state
17
+ * survives reopen (database adapters only).
18
+ */
19
+ readonly exerciseReopen?: boolean;
11
20
  }
21
+ export type SessionStoreConformanceFactory = () => SessionStore | Promise<SessionStore>;
12
22
  /**
13
23
  * Assert that a `SessionStore` implementation satisfies the core adapter
14
24
  * contract: round-trip append/list, duplicate-entry-id rejection,
@@ -18,3 +28,9 @@ export interface SessionStoreConformanceOptions {
18
28
  * violation; returns silently when the store conforms.
19
29
  */
20
30
  export declare function assertSessionStoreConforms(store: SessionStore, options?: SessionStoreConformanceOptions): Promise<void>;
31
+ /**
32
+ * Factory-based conformance entry point for durable adapters. Calls the factory
33
+ * to obtain a store, runs the full contract, and optionally reopens through the
34
+ * same factory to assert idempotency rows and entries survive restart.
35
+ */
36
+ export declare function runSessionStoreConformance(factory: SessionStoreConformanceFactory, options?: SessionStoreConformanceOptions): Promise<void>;
@@ -63,6 +63,79 @@ export async function assertSessionStoreConforms(store, options = {}) {
63
63
  throw new Error(`readBranchPath must return the ancestor chain root→leaf in order; got ${JSON.stringify(ids)}`);
64
64
  }
65
65
  }
66
+ await assertSessionStoreBranchIsolation(store, options);
67
+ if (options.exerciseConcurrentParentAppend) {
68
+ await assertConcurrentParentAppendAllowed(store, sessionId, now, make);
69
+ }
70
+ }
71
+ /**
72
+ * Factory-based conformance entry point for durable adapters. Calls the factory
73
+ * to obtain a store, runs the full contract, and optionally reopens through the
74
+ * same factory to assert idempotency rows and entries survive restart.
75
+ */
76
+ export async function runSessionStoreConformance(factory, options = {}) {
77
+ const store = await factory();
78
+ await assertSessionStoreConforms(store, options);
79
+ if (!options.exerciseReopen)
80
+ return;
81
+ const sessionId = options.sessionId ?? "conformance";
82
+ const listed = await store.list(sessionId);
83
+ const parent = listed.at(-1);
84
+ await store.append({
85
+ id: "reopen-target",
86
+ parentId: parent?.id,
87
+ sessionId,
88
+ timestamp: "2026-01-01T00:00:01.000Z",
89
+ kind: "label",
90
+ label: "reopen",
91
+ }, { idempotencyKey: "reopen-idem", expectedParentId: parent?.id });
92
+ const before = (await store.list(sessionId)).map((entry) => entry.id);
93
+ const reopened = await factory();
94
+ const after = (await reopened.list(sessionId)).map((entry) => entry.id);
95
+ if (before.join("\u0000") !== after.join("\u0000")) {
96
+ throw new Error("Session entries did not survive adapter reopen");
97
+ }
98
+ await reject(() => reopened.append({
99
+ id: "reopen-idem-dup",
100
+ parentId: parent?.id,
101
+ sessionId,
102
+ timestamp: "2026-01-01T00:00:01.000Z",
103
+ kind: "label",
104
+ label: "dup",
105
+ }, { idempotencyKey: "reopen-idem", expectedParentId: parent?.id }), (error) => isSessionAppendConflict(error) && error.conflict.idempotencyDuplicate === true, "Restarted store must still deduplicate an exact idempotency retry");
106
+ }
107
+ async function assertSessionStoreBranchIsolation(store, options) {
108
+ const sessionId = options.sessionId ?? "conformance";
109
+ const otherSessionId = options.otherSessionId ?? `${sessionId}-other`;
110
+ const entry = {
111
+ id: "isolation-entry",
112
+ sessionId: otherSessionId,
113
+ timestamp: "2026-01-01T00:00:00.000Z",
114
+ kind: "label",
115
+ label: "isolated",
116
+ };
117
+ await store.append(entry);
118
+ const primary = await store.list(sessionId);
119
+ if (primary.some((row) => row.id === "isolation-entry")) {
120
+ throw new Error("SessionStore leaked entries across session ids");
121
+ }
122
+ }
123
+ async function assertConcurrentParentAppendAllowed(store, sessionId, now, make) {
124
+ const forkRoot = make("fork-root");
125
+ await store.append(forkRoot);
126
+ const childA = make("fork-a", forkRoot.id);
127
+ const childB = make("fork-b", forkRoot.id);
128
+ const results = await Promise.allSettled([
129
+ store.append(childA, { expectedParentId: forkRoot.id }),
130
+ store.append(childB, { expectedParentId: forkRoot.id }),
131
+ ]);
132
+ const succeeded = results.filter((result) => result.status === "fulfilled").length;
133
+ if (succeeded === 0)
134
+ throw new Error("Concurrent append to an existing parent rejected both writers; at least one fork child must succeed");
135
+ const listed = await store.list(sessionId);
136
+ if (!listed.some((entry) => entry.id === "fork-a") && !listed.some((entry) => entry.id === "fork-b")) {
137
+ throw new Error("Concurrent parent append wrote no child entries");
138
+ }
66
139
  }
67
140
  function assertIds(entries, expected, message) {
68
141
  const actual = entries.map((entry) => entry.id);
package/dist/tools.d.ts CHANGED
@@ -9,6 +9,23 @@ export interface ToolFilter {
9
9
  }
10
10
  export type ToolFilterInput = ToolFilter | readonly ToolFilter[];
11
11
  export type ToolValidator = (tool: ToolDefinition, args: JsonObject, context: ToolExecutionContext) => void | string | ErrorInfo | Promise<void | string | ErrorInfo>;
12
+ export interface ToolArgumentValidationError {
13
+ readonly path?: string;
14
+ readonly message: string;
15
+ }
16
+ export interface ToolArgumentValidationResult {
17
+ readonly ok: boolean;
18
+ readonly errors?: readonly ToolArgumentValidationError[];
19
+ }
20
+ export interface ToolArgumentValidator {
21
+ validate(schema: JsonObject, value: unknown): ToolArgumentValidationResult;
22
+ }
23
+ export interface ToolParameterValidatorOptions {
24
+ /** When a tool omits `parameters`. Default `"allow"` preserves pre-validation behavior. */
25
+ readonly missingSchema?: "allow" | "reject";
26
+ }
27
+ /** Wrap a schema adapter as the existing `ToolValidator` seam used by dispatch and the agent runtime. */
28
+ export declare function createToolParameterValidator(validator: ToolArgumentValidator, options?: ToolParameterValidatorOptions): ToolValidator;
12
29
  export interface DispatchToolCallOptions {
13
30
  readonly call: ToolCallContent;
14
31
  readonly registry: ToolRegistry;
package/dist/tools.js CHANGED
@@ -2,6 +2,26 @@ import { isJsonObject } from "./config.js";
2
2
  import { errorToErrorInfo, redactRunLedgerRecord, redactSecrets } from "./redaction.js";
3
3
  import { assertCanRegister } from "./registry-options.js";
4
4
  import { assertPermission } from "./security.js";
5
+ /** Wrap a schema adapter as the existing `ToolValidator` seam used by dispatch and the agent runtime. */
6
+ export function createToolParameterValidator(validator, options = {}) {
7
+ const missingSchema = options.missingSchema ?? "allow";
8
+ return (tool, args) => {
9
+ if (!tool.parameters) {
10
+ if (missingSchema === "reject")
11
+ return `Tool ${tool.name} has no parameters schema`;
12
+ return undefined;
13
+ }
14
+ const result = validator.validate(tool.parameters, args);
15
+ if (result.ok)
16
+ return undefined;
17
+ return formatToolArgumentValidationErrors(tool.name, result.errors);
18
+ };
19
+ }
20
+ function formatToolArgumentValidationErrors(toolName, errors) {
21
+ if (!errors?.length)
22
+ return `Tool arguments failed validation: ${toolName}`;
23
+ return errors.map((error) => (error.path ? `${error.path}: ${error.message}` : error.message)).join("; ");
24
+ }
5
25
  export function createToolRegistry(tools = [], options = {}) {
6
26
  const byName = new Map();
7
27
  const registry = {
@@ -32,6 +52,9 @@ export function filterTools(tools, filter) {
32
52
  const allows = filters.map((item) => item.allow?.length ? new Set(item.allow) : undefined).filter((item) => Boolean(item));
33
53
  return tools.filter((tool) => !denied.has(tool.name) && allows.every((allow) => allow.has(tool.name)));
34
54
  }
55
+ function toolExecutionMetadata(startedAt, status) {
56
+ return { durationMs: Math.max(0, Date.now() - Date.parse(startedAt)), status };
57
+ }
35
58
  export async function dispatchToolCall(options) {
36
59
  const secrets = options.secrets ?? [];
37
60
  const startedAt = new Date().toISOString();
@@ -80,7 +103,8 @@ export async function dispatchToolCall(options) {
80
103
  const mediatedResult = await (options.middleware?.run("tool_result", raw) ?? raw);
81
104
  const result = options.redactor?.redact(mediatedResult) ?? mediatedResult;
82
105
  const finishedAt = new Date().toISOString();
83
- await options.emit?.({ type: "tool_execution_finished", sessionId: context.sessionId, runId: context.runId, result });
106
+ const metadata = toolExecutionMetadata(startedAt, "finished");
107
+ await options.emit?.({ type: "tool_execution_finished", sessionId: context.sessionId, runId: context.runId, result, metadata });
84
108
  await appendToolCallRecord(options, "finished", mediatedCall, startedAt, { finishedAt, result });
85
109
  return result;
86
110
  }
@@ -88,7 +112,8 @@ export async function dispatchToolCall(options) {
88
112
  const info = errorToErrorInfo(error, secrets);
89
113
  const result = { toolCallId: mediatedCall.id, name: mediatedCall.name, error: info };
90
114
  const finishedAt = new Date().toISOString();
91
- await options.emit?.({ type: "tool_execution_error", sessionId: context.sessionId, runId: context.runId, call: mediatedCall, error: info });
115
+ const metadata = toolExecutionMetadata(startedAt, "error");
116
+ await options.emit?.({ type: "tool_execution_error", sessionId: context.sessionId, runId: context.runId, call: mediatedCall, error: info, metadata });
92
117
  await appendToolCallRecord(options, "error", mediatedCall, startedAt, { finishedAt, result });
93
118
  return result;
94
119
  }
@@ -105,6 +130,7 @@ async function checkCall(call, options, startedAt) {
105
130
  return undefined;
106
131
  }
107
132
  async function blocked(call, context, reason, error, options, startedAt) {
133
+ const metadata = toolExecutionMetadata(startedAt, "blocked");
108
134
  await options.emit?.({
109
135
  type: "tool_execution_blocked",
110
136
  sessionId: context.sessionId,
@@ -113,6 +139,7 @@ async function blocked(call, context, reason, error, options, startedAt) {
113
139
  name: call.name,
114
140
  reason,
115
141
  error,
142
+ metadata,
116
143
  });
117
144
  const finishedAt = new Date().toISOString();
118
145
  const result = { toolCallId: call.id, name: call.name, error };
@@ -40,6 +40,7 @@ The `AgentEvent` union (grouped by concern):
40
40
  | --- | --- |
41
41
  | Agent lifecycle | `agent_started`, `agent_finished` |
42
42
  | Turns | `turn_started`, `turn_finished` |
43
+ | Provider turns | `provider_turn_started`, `provider_turn_finished` |
43
44
  | Assistant messages | `message_started`, `message_delta`, `message_finished` |
44
45
  | Tool execution | `tool_execution_started`, `tool_execution_progress`, `tool_execution_finished`, `tool_execution_error`, `tool_execution_blocked` |
45
46
  | Queue/subscribers | `queue_updated`, `event_subscriber_overflow` |
@@ -70,11 +71,11 @@ Tool execution events:
70
71
  | --- | --- |
71
72
  | `tool_execution_started` | `sessionId`, `runId`, `call: ToolCallContent` |
72
73
  | `tool_execution_progress` | `sessionId`, `runId`, `toolCallId`, `name`, `progress?`, `metadata?` |
73
- | `tool_execution_finished` | `sessionId`, `runId`, `result: ToolResult` |
74
- | `tool_execution_error` | `sessionId`, `runId`, `call: ToolCallContent`, `error: ErrorInfo` |
75
- | `tool_execution_blocked` | `sessionId`, `runId`, `toolCallId`, `name`, `reason: string`, `error: ErrorInfo` |
74
+ | `tool_execution_finished` | `sessionId`, `runId`, `result: ToolResult`, `metadata: ToolExecutionMetadata` |
75
+ | `tool_execution_error` | `sessionId`, `runId`, `call: ToolCallContent`, `error: ErrorInfo`, `metadata: ToolExecutionMetadata` |
76
+ | `tool_execution_blocked` | `sessionId`, `runId`, `toolCallId`, `name`, `reason: string`, `error: ErrorInfo`, `metadata: ToolExecutionMetadata` |
76
77
 
77
- Queue / subscriber / compaction / retry events:
78
+ Queue / subscriber / compaction / retry / provider events:
78
79
 
79
80
  | Variant | Fields |
80
81
  | --- | --- |
@@ -84,6 +85,13 @@ Queue / subscriber / compaction / retry events:
84
85
  | `compaction_finished` | `sessionId`, `runId?`, `summary: string` |
85
86
  | `retry_scheduled` | `sessionId`, `runId`, `attempt: number`, `delayMs: number`, `error: ErrorInfo` |
86
87
 
88
+ Provider turn events (metadata only — see [Observability](observability.md)):
89
+
90
+ | Variant | Fields |
91
+ | --- | --- |
92
+ | `provider_turn_started` | `sessionId`, `runId`, `turn`, `metadata: ProviderTurnMetadata` |
93
+ | `provider_turn_finished` | `sessionId`, `runId`, `turn`, `metadata` (includes `latencyMs` on finish), `usage?`, `error?` |
94
+
87
95
  Artifact validation/refinement events (emitted only by `generateValidateReviseLoop`; `singleShotLoop` emits zero artifact events):
88
96
 
89
97
  | Variant | Fields |
@@ -195,5 +203,6 @@ await session.run("draft", { loop: { strategy: "generate-validate-revise", valid
195
203
  - [Agent loops](agent-loops.md): `singleShotLoop` and `generateValidateReviseLoop` emit the artifact events.
196
204
  - [Structured output](structured-output.md): `ArtifactValidation` shape threaded through parser/validator/repairer.
197
205
  - [Public contracts](public-contracts.md): full `AgentEvent` union and `ArtifactValidation` contract.
206
+ - [Observability](observability.md): `ProviderTurnMetadata`, OpenTelemetry adapter package.
198
207
  - [Tools](tools.md): `tool_execution_*` variants.
199
208
  - [Compaction and retry policies](compaction-and-retry.md): `compaction_*` and `retry_scheduled` variants.
@@ -46,7 +46,7 @@ const agent = createAgent({
46
46
  model,
47
47
  provider,
48
48
  // optional default loop for this agent:
49
- loop: { strategy: "single-shot" },
49
+ loop: { strategy: "single-shot", toolConcurrency: 4 },
50
50
  });
51
51
 
52
52
  // RunOptions.loop overrides per request.
@@ -68,7 +68,11 @@ await session.run(input, { loop: myCustomLoop });
68
68
 
69
69
  ```ts
70
70
  type AgentLoopOptions =
71
- | { readonly strategy: "single-shot" }
71
+ | {
72
+ readonly strategy: "single-shot";
73
+ /** Independent tool calls per turn run concurrently up to this limit. Default `1`. */
74
+ readonly toolConcurrency?: number;
75
+ }
72
76
  | {
73
77
  readonly strategy: "generate-validate-revise";
74
78
  readonly validator: ArtifactValidator<unknown>;
@@ -95,7 +99,7 @@ Host callback contracts (all generic over host `T`):
95
99
  | --- | --- |
96
100
  | `sessionId`, `runId`, `metadata`, `signal` | Run identity and abort. |
97
101
  | `history: Message[]` | Live mutable history — the loop pushes assistant and repair messages directly. |
98
- | `input`, `inputMessages`, `maxToolRounds` | First-turn input, the redacted input messages, and the tool-round budget (single-shot parity hooks). |
102
+ | `input`, `inputMessages`, `maxToolRounds`, `toolConcurrency` | First-turn input, redacted input messages, tool-round budget, and per-turn parallel dispatch limit (`toolConcurrency` default `1`). |
99
103
  | `assemble(nextInput, toolResults?)` | Wraps `assembleProviderInput()` with resolved skills/tools/context/system prompt/provider options. |
100
104
  | `generate(request)` | Wraps provider request policies + `provider_request` middleware + `generateWithRetry()`; returns `ProviderTurnResult`. |
101
105
  | `dispatchToolCall(call)` | Wraps `dispatchToolCall()` with resolved registry/middleware/permission/redactor/validate. |
@@ -195,7 +199,8 @@ await session.run(input, { loop: twoShotLoop });
195
199
  - `{ strategy: "single-shot" }` resolves to the exported `singleShotLoop`; `{ strategy: "generate-validate-revise", ... }` is mapped by `resolveLoop()` to `generateValidateReviseLoop(opts)`. An unknown `strategy` throws before the first turn. Passing an `AgentLoopStrategy` instance bypasses the options form entirely (custom-loop escape hatch).
196
200
  - The loop is resolved once per run inside `RuntimeAgentSession.run()`, after the usual setup (provider/skills/tools resolution, history rebuild, model-change entry, input append, auto-compaction). The runtime's outer try/catch/finally, run-exclusivity, abort bridging, and subscriber close remain in place around `loop.run(ctx)`.
197
201
  - `LoopContext.assemble(nextInput, toolResults?)` accepts an optional tool-result accumulator so `singleShotLoop` can pass its loop-local `toolResults`; `generateValidateReviseLoop` omits it (no tools in revision turns).
198
- - `maxToolRounds` bounds `singleShotLoop` tool rounds; `maxRevisions` (default 3) bounds `generateValidateReviseLoop` revision turns. Budget exhaustion ends the loop and returns the last usage; it does not throw.
202
+ - `maxToolRounds` bounds `singleShotLoop` tool rounds; `toolConcurrency` (default `1`) bounds how many independent tool calls from one provider turn may execute concurrently. Results and transcript rows are still appended in original call order. If any resolved `ToolDefinition` in a turn has `exclusive: true`, that turn uses concurrency `1`; later non-exclusive turns restore configured concurrency.
203
+ - `maxRevisions` (default 3) bounds `generateValidateReviseLoop` revision turns. Budget exhaustion ends the loop and returns the last usage; it does not throw.
199
204
  - A revision cycle appends one assistant draft and one repair user message per revision to the session store, so store entries reflect every attempted draft. The original user input is stored once by the runtime and pushed into loop history once on the first turn.
200
205
 
201
206
  ## Security and performance notes
@@ -203,6 +208,7 @@ await session.run(input, { loop: twoShotLoop });
203
208
  - Loops have no path to credentials, provider objects, or unredacted secrets. `LoopContext.generate` consumes an already-redacted request; `LoopContext.emit` runs through `redactAgentEvent` with the active `SecretRedactor`; `LoopContext.appendMessage` appends a redacted entry.
204
209
  - `ArtifactValidation.errors[].message` may echo model text — `artifact_*` event payloads flow through the same `redactAgentEvent` path as other `AgentEvent`s (see [Agent events](agent-events.md)).
205
210
  - `generateValidateReviseLoop` makes at most `maxRevisions + 1` provider turns; it cannot loop forever on an always-failing validator. Each revision costs one provider turn plus one store append.
211
+ - Parallel tool dispatch uses a bounded worker pool over the calls in one turn; queue depth is `calls.length`, not unbounded. Exclusive turns use the same sequential path. Each call still runs through `dispatchToolCall` (permission + validation + execute). Tool lifecycle events may complete out of order; history/store appends stay in call order.
206
212
  - The loop is a plain object/factory; no class hierarchy, no background work, no extra dependencies. `LoopContext` is a single object literal of bound arrows built once per run.
207
213
  - The Synapta-free boundary is guarded by tests: `src/` imports no `synapta*` package, and the `Artifact*`/`AgentLoop*`/`LoopContext` contracts contain no `workflow`/`node`/`step` field names. Hosts supply their own schema; no host domain type is imported by `src/`.
208
214
 
@@ -173,6 +173,7 @@ await agent.createSession().run("Hi", { model: overrideModel });
173
173
  - [Tools](tools.md): host-owned tool harness used by the bounded runtime tool loop.
174
174
  - [Middleware hooks](middleware-hooks.md): hooks that configured assembly/runtime can run.
175
175
  - [CLI/RPC](cli-rpc.md): terminal and JSONL adapters over this runtime.
176
+ - [Workflows](workflows.md): optional DAG orchestration that calls `AgentSession.run()` for agent nodes.
176
177
 
177
178
  `AgentConfig.loop` and `RunOptions.loop` select a replaceable per-run control loop (`singleShotLoop` default, or `generate-validate-revise` with host callbacks); see [Agent loops](agent-loops.md). `RunOptions.loop` wins over `AgentConfig.loop`. Built-in loops emit the same normal turn/message envelope around provider turns, and both add the first run input to live history once after the first provider turn so later turns see the same transcript shape.
178
179
 
package/docs/cli-rpc.md CHANGED
@@ -147,6 +147,8 @@ CLI/RPC are adapters over `AgentSession`. They do not scan packages, import exte
147
147
 
148
148
  RPC `command` executes only explicitly registered `CommandDefinition` values. `setModel` stores a model override for later prompt/follow-up calls. `compact`, `switchSession`, `forkSession`, `cloneSession`, and `checkout` call the existing session APIs.
149
149
 
150
+ Optional workflow control (from `@arnilo/prism-workflows`) registers `workflow.start`, `workflow.status`, `workflow.list`, `workflow.cancel`, and `workflow.resume` via `createWorkflowCommands({ workflows, checkpoints, runOptions? })`. Pass the returned `CommandDefinition[]` into `runRpcServer({ commands })` the same way as observational-memory commands. Cancel aborts in-process runs through the package active-run registry; orphaned durable checkpoints still marked `running` are fail-closed to `aborted`.
151
+
150
152
  `forkSession` creates another handle for the same `sessionId` and selected `leafId`; it no longer overwrites the parent handle in the RPC map. Keep the returned `handleId` when a UI needs to switch among sibling branches. `switchSession` accepts `handleId` (preferred), `sessionId`, or `id`; with multiple branch handles, use `handleId` to avoid ambiguity. `checkout` requires `params.leafId`, calls `AgentSession.checkout(leafId)`, and keeps the active handle id unchanged while moving that handle to the existing leaf. `messages` returns entries for the active branch path.
151
153
 
152
154
  ## Security and performance notes
@@ -169,6 +171,7 @@ RPC `command` executes only explicitly registered `CommandDefinition` values. `s
169
171
  - [Resource loading](resource-loading.md): explicit resource loading primitives.
170
172
  - [Credentials and redaction](credentials-and-redaction.md): secret redaction helpers and credential boundaries.
171
173
  - [Observational memory compaction package](compaction-observational-memory.md): optional `om:status` and `om:view` command factories for explicitly wired hosts.
174
+ - [Workflows](workflows.md): optional `createWorkflowCommands()` for start/status/list/cancel/resume over the same RPC `command` seam.
172
175
 
173
176
  The CLI records flags but does not auto-load project-local resources, extensions, tools, or config. The two system/project prompt files are the exception: in print/json modes the CLI auto-loads `<workspaceRoot>/AGENTS.md` (trust-gated) and an app-supplied `SYSTEM.md` layer as `AgentConfig.systemPrompt` layers composed with `--system` (base); `--no-agents-md` / `--no-system-md` skip them and `--agents-md-file` / `--system-md-file` override the paths. The CLI does not default `globalRoot` to the user's home directory — pass it from a host adapter or use `--agents-config <path>` for the app-config bundle layout. RPC mode does not auto-read these files (the host owns the session factory). Hosts must make explicit trust and permission decisions before wiring any other local loading.
174
177
 
@@ -14,6 +14,8 @@
14
14
  | `createReadOnlyTools(cwd, options?)` | Read-only subset: `read` only. |
15
15
  | `createAllTools(cwd, options?)` | Every tool the package provides (currently identical to `createCodingTools`). |
16
16
  | `detectSupportedImageMimeType(buf)` / `detectSupportedImageMimeTypeFromFile(path)` | Magic-byte image MIME detection (PNG/JPEG/GIF/WebP/BMP) used by `read`. |
17
+ | `DEFAULT_MAX_IMAGE_BYTES` | Default `read` image size ceiling (10 MB). |
18
+ | `TransformImage` / `TransformImageInput` | Types for the optional `read` `transformImage` callback. |
17
19
  | `withFileMutationQueue(path, fn)` | Per-path serialization primitive re-exported for hosts. |
18
20
 
19
21
  Each factory returns a plain `ToolDefinition` (no auto-registration). Register what you need:
@@ -29,7 +31,19 @@ const tools = createToolRegistry(createCodingTools(process.cwd()));
29
31
 
30
32
  Use this package when a host wants ready-made coding tools for an agent, session, or run, registered explicitly into a `ToolRegistry` and dispatched through the normal Prism tool harness. The tools perform **real** shell and filesystem operations on the host — they are not mocked or sandboxed. Use the individual factories when you need per-tool options or custom operation backends; use the aggregators when you want the default set.
31
33
 
32
- Do not use this package as a sandbox, permission policy, secret store, or provider loop. Prism gates tool dispatch with `PermissionPolicy` / `ToolValidator` / trust policies; the package performs no gating of its own. Do not register these tools for an untrusted provider.
34
+ Do not use this package as a sandbox, permission policy, secret store, or provider loop. Prism gates tool dispatch with `PermissionPolicy` / `ToolValidator` / trust policies; pass an optional `ExecutionPolicy` (for example from `@arnilo/prism-coding-security`) for path/command approval before side effects. Do not register these tools for an untrusted provider.
35
+
36
+ ```ts
37
+ import { createCodingTools } from "@arnilo/prism-coding-agent";
38
+ import { createCodingApprovalPolicy } from "@arnilo/prism-coding-security";
39
+
40
+ const tools = createCodingTools(workspaceRoot, {
41
+ executionPolicy: createCodingApprovalPolicy({
42
+ roots: [workspaceRoot],
43
+ approve: async ({ action }) => host.confirm(action),
44
+ }),
45
+ });
46
+ ```
33
47
 
34
48
  ### pi name mapping
35
49
 
@@ -40,7 +54,7 @@ Do not use this package as a sandbox, permission policy, secret store, or provid
40
54
  | `write` | `write` |
41
55
  | `edit` | `edit` |
42
56
 
43
- ## Tools
57
+ ## Inputs / request
44
58
 
45
59
  ### `shell`
46
60
 
@@ -77,16 +91,36 @@ Read a text or image file.
77
91
  | `offset` | `number` | Line to start reading from (1-indexed). |
78
92
  | `limit` | `number` | Maximum number of lines to read. |
79
93
 
80
- **Outputs:** text files become a single `TextContent`, truncated to `maxLines`/`maxBytes` (defaults 2000 lines / 50 KB) with a `Use offset=N to continue` footer when more remains. Image files (PNG/JPEG/GIF/WebP/BMP by magic bytes) become `[TextContent note, ImageContent]` with base64 `data` and `mimeType`. Read failures (missing file, offset beyond end, abort) are error results.
94
+ **Outputs:** text files become a single `TextContent`, truncated to `maxLines`/`maxBytes` (defaults 2000 lines / 50 KB) with a `Use offset=N to continue` footer when more remains. Image files (PNG/JPEG/GIF/WebP/BMP by **magic bytes**, not extension) become `[TextContent note, ImageContent]` with base64 `data` and `mimeType`. Oversize images are rejected by `stat` (when available) or `buffer.length` against `maxImageBytes` (default 10 MB) before base64 encoding. An optional `transformImage` callback lets hosts resize or re-encode images without adding image-processing dependencies to the base package. Read failures (missing file, offset beyond end, oversize image, abort) are error results.
95
+
96
+ `read` tool options (via `createReadTool(cwd, options)` or `ToolsOptions.read`):
97
+
98
+ | Option | Default | Purpose |
99
+ | --- | --- | --- |
100
+ | `maxImageBytes` | `DEFAULT_MAX_IMAGE_BYTES` (10 MB) | Reject image reads larger than this many bytes. |
101
+ | `transformImage` | — | Host callback `( { buffer, mimeType } ) => Promise<Buffer>` run after read, before base64. |
102
+ | `autoResizeImages` | — | **Deprecated.** Ignored unless `transformImage` is also set (use `transformImage` instead). |
103
+ | `maxLines` / `maxBytes` | 2000 / 50 KB | Text head truncation limits. |
104
+ | `operations` | local fs | Pluggable `ReadOperations` backend. |
105
+ | `executionPolicy` | — | Structured pre-execution policy (see [Coding security](coding-security.md)). |
106
+
107
+ ```ts
108
+ import { createReadTool, DEFAULT_MAX_IMAGE_BYTES } from "@arnilo/prism-coding-agent";
109
+
110
+ const read = createReadTool(cwd, {
111
+ maxImageBytes: DEFAULT_MAX_IMAGE_BYTES,
112
+ transformImage: async ({ buffer, mimeType }) => host.resizeImage(buffer, mimeType),
113
+ });
114
+ ```
81
115
 
82
116
  `read` result `metadata`:
83
117
 
84
118
  | Field | Present when | Purpose |
85
119
  | --- | --- | --- |
86
120
  | `truncation` | text reads | `TruncationResult`. |
87
- | `image` | image reads | `{ mimeType, resized: false }`. |
121
+ | `image` | image reads | `{ mimeType, resized, bytes }`. `resized` is `true` when `transformImage` ran. |
88
122
 
89
- > `autoResizeImages` is accepted but is currently a documented no-op (deferred); images are returned at their original size with `image.resized = false`.
123
+ > `autoResizeImages` is deprecated. It has no effect without `transformImage`; use `transformImage` for host-owned resizing.
90
124
 
91
125
  ### `write`
92
126
 
@@ -187,7 +221,7 @@ const remoteWrite = createWriteTool("/repo", {
187
221
  ## Extension and configuration notes
188
222
 
189
223
  - **Pluggable operation backends.** Every tool accepts an `operations` seam so a host can delegate to a remote system (e.g. SSH) while keeping the tool's matching/serialization behavior: `BashOperations` (`shell`), `ReadOperations` (`read`), `WriteOperations` (`write`), `EditOperations` (`edit`).
190
- - **Per-tool options.** `ShellToolOptions` (`shellPath`, `commandPrefix`, `maxLines`, `maxBytes`, `tempFilePrefix`, `operations`, `spawnHook`); `ReadToolOptions` (`operations`, `autoResizeImages`, `maxLines`, `maxBytes`); `WriteToolOptions` (`operations`); `EditToolOptions` (`operations`).
224
+ - **Per-tool options.** `ShellToolOptions` (`shellPath`, `commandPrefix`, `maxLines`, `maxBytes`, `tempFilePrefix`, `operations`, `spawnHook`, `executionPolicy`); `ReadToolOptions` (`operations`, `maxImageBytes`, `transformImage`, `maxLines`, `maxBytes`, `executionPolicy`; `autoResizeImages` deprecated); `WriteToolOptions` (`operations`, `executionPolicy`); `EditToolOptions` (`operations`, `executionPolicy`).
191
225
  - **Aggregator options.** `ToolsOptions` (`{ shell?, read?, write?, edit? }`) threads each sub-object to the matching tool.
192
226
  - **`ToolsOptions`** and the per-tool option types are exported from the package barrel for host configuration.
193
227
  - No auto-discovery or manifest registration: import and register explicitly. This package registers no extensions and owns no globals (the mutation queue is a process-wide per-path map — see `ponytail:` note in the source).
@@ -198,7 +232,7 @@ const remoteWrite = createWriteTool("/repo", {
198
232
  - **Non-zero exit is not an error.** A failing command is a normal `shell` result (exit code in metadata); only timeout/abort/spawn failures are error results. Do not assume `error == undefined` means the command succeeded.
199
233
  - **Bounded output.** `shell`/`read` accumulate output into a rolling tail bounded by `maxLines`/`maxBytes`; oversized output spills to a temp file (`fullOutputPath`), so memory use is bounded regardless of command output size.
200
234
  - **Per-path serialization.** Concurrent mutations to the same file serialize; concurrent mutations to different files do not block each other. The queue is a process-wide map — across sessions in one process, same-path writes still serialize (upgrade path: scope per registry if throughput matters).
201
- - **Single runtime dependency.** `diff` (for `edit` unified patch/diff generation) plus the Node standard library. No native modules; no image-processing native dependency (image auto-resize is deferred, so no `sharp`/WASM dependency).
235
+ - **Bounded image reads.** `read` rejects images over `maxImageBytes` (default 10 MB) by `stat` before read when possible; MIME is detected from magic bytes only. Optional `transformImage` is host-owned — the base package has no image-processing dependency.
202
236
 
203
237
  ## Related APIs
204
238