@jini-ai/daemon 0.6.6 → 0.6.8

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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 0.6.8 — 2026-10-16
2
+
3
+ - An unknown tool on the read-only gateway points to discovery, never a mutation 409 (75d37c32).
4
+
5
+ ## 0.6.7 — 2026-10-15
6
+
7
+ - An unknown tool id is a `400 BAD_REQUEST`, not a redacted `500 INTERNAL_ERROR` (b4a3abad): `ToolExecutor` throws `ToolInputError` naming up to eight registered ids the unknown id prefixes (e.g. `content_read` → `content_read.menu`), and the delegated-tools route returns that message.
8
+
1
9
  ## 0.6.6 — 2026-10-14
2
10
 
3
11
  - A failed jini MCP bridge now says why (8bc0f07d): the run still stops at once, but a disposable credential-free copy of the bridge is spawned, sent MCP `initialize`, and its exit code + stderr tail (e.g. `ERR_MODULE_NOT_FOUND`) go into the `MCP_BRIDGE_UNAVAILABLE` message and `error.details`, emitted before the run's end event. `probeMcpBridge` is exported so hosts can run the same check at boot; `mcpJsonInjection.probeOnFailure: false` opts out. The baseline agent env moves to `agent-env.ts` so probe and launch share one allowlist.
@@ -25,8 +25,8 @@ export interface ReadOnlyConstrainedPrincipal extends Principal {
25
25
  readonly toolAccess: 'read-only';
26
26
  }
27
27
  /**
28
- * Default daemon refusal wording, replaceable by the host. Unknown registrations and writes share
29
- * one refusal: neither corroborates a safe read, and catalog discovery already reveals existence.
28
+ * Default daemon refusal wording, replaceable by the host. Unknown registrations and writes both
29
+ * refuse unsafe dispatch; unknown ids direct discovery because an unrestricted retry cannot run them.
30
30
  * Missing verification metadata always refuses, naming the missing wiring so the host can fix it.
31
31
  */
32
32
  export declare const defaultDaemonMessages: {
@@ -35,6 +35,10 @@ export declare const defaultDaemonMessages: {
35
35
  toolRefusalMessage: ({ toolId }: {
36
36
  toolId: string;
37
37
  }) => string;
38
+ unknownToolMessage: ({ toolId, candidates }: {
39
+ toolId: string;
40
+ candidates: readonly string[];
41
+ }) => string;
38
42
  };
39
43
  };
40
44
  /** Required host wording; absent metadata is always a refusal. */
@@ -43,6 +47,10 @@ export interface ReadOnlyToolMessages {
43
47
  toolRefusalMessage(required: {
44
48
  toolId: string;
45
49
  }): string;
50
+ unknownToolMessage(required: {
51
+ toolId: string;
52
+ candidates: readonly string[];
53
+ }): string;
46
54
  }
47
55
  export interface ReadOnlyToolCheckRequired {
48
56
  readonly toolId: string;
@@ -63,7 +71,7 @@ export declare function constrainPrincipalToReadOnlyTools({ principal }: {
63
71
  export declare function principalIsReadOnlyConstrained({ principal }: {
64
72
  principal: Principal;
65
73
  }, _optional?: Record<string, never>): boolean;
66
- /** @returns Required host wording for an unknown or non-read-only tool. */
74
+ /** @returns Required host wording for a registered non-read-only tool. */
67
75
  export declare function readOnlyToolRefusalMessage({ toolId, messages }: {
68
76
  toolId: string;
69
77
  messages: ReadOnlyToolMessages;
@@ -1,13 +1,15 @@
1
1
  import { isReadOnlyTool } from '@jini-ai/core';
2
+ import { toolCandidates } from './tool-candidates.js';
2
3
  /**
3
- * Default daemon refusal wording, replaceable by the host. Unknown registrations and writes share
4
- * one refusal: neither corroborates a safe read, and catalog discovery already reveals existence.
4
+ * Default daemon refusal wording, replaceable by the host. Unknown registrations and writes both
5
+ * refuse unsafe dispatch; unknown ids direct discovery because an unrestricted retry cannot run them.
5
6
  * Missing verification metadata always refuses, naming the missing wiring so the host can fix it.
6
7
  */
7
8
  export const defaultDaemonMessages = {
8
9
  readOnly: {
9
10
  unverifiableMessage: 'this host cannot verify read-only tools — POST /api/delegated-tool-calls was mounted without DelegatedToolsHttpDeps.toolRegistry, so a requireReadOnly call cannot be checked and is refused',
10
11
  toolRefusalMessage: ({ toolId }) => `tool "${toolId}" is not registered as read-only — this gateway executes only tools whose registration declares readOnly; call it through execute_delegated_tool instead`,
12
+ unknownToolMessage: ({ toolId, candidates }) => `no tool "${toolId}" is registered; use search_tools${candidates.length > 0 ? ` — did you mean ${candidates.join(', ')}?` : ''}`,
11
13
  },
12
14
  };
13
15
  /** @returns A copied, attenuated principal; its roles are unchanged. */
@@ -20,7 +22,7 @@ export function constrainPrincipalToReadOnlyTools({ principal }, _optional = {})
20
22
  export function principalIsReadOnlyConstrained({ principal }, _optional = {}) {
21
23
  return 'toolAccess' in principal && principal.toolAccess === 'read-only';
22
24
  }
23
- /** @returns Required host wording for an unknown or non-read-only tool. */
25
+ /** @returns Required host wording for a registered non-read-only tool. */
24
26
  export function readOnlyToolRefusalMessage({ toolId, messages }, _optional = {}) {
25
27
  return messages.toolRefusalMessage({ toolId });
26
28
  }
@@ -34,7 +36,10 @@ export function readOnlyToolRefusalMessage({ toolId, messages }, _optional = {})
34
36
  export function checkReadOnlyTool({ toolId, registry, messages }, _optional = {}) {
35
37
  if (registry === undefined)
36
38
  return messages.unverifiableMessage;
37
- const descriptor = registry.list({}).find((candidate) => candidate.id === toolId);
39
+ const tools = registry.list({});
40
+ const descriptor = tools.find((candidate) => candidate.id === toolId);
41
+ if (!descriptor)
42
+ return messages.unknownToolMessage({ toolId, candidates: toolCandidates({ toolId, tools }) });
38
43
  if (isReadOnlyTool({ descriptor }))
39
44
  return null;
40
45
  return readOnlyToolRefusalMessage({ toolId, messages });
@@ -0,0 +1,8 @@
1
+ import type { ToolDescriptor } from '@jini-ai/core';
2
+ /** Registered prefix matches for unknown-tool diagnostics, in catalog order, capped at eight.
3
+ * @complexity O(n) time and space over n registered tools. */
4
+ export declare function toolCandidates({ toolId, tools }: {
5
+ toolId: string;
6
+ tools: readonly Pick<ToolDescriptor, 'id'>[];
7
+ }, _optional?: Record<string, never>): string[];
8
+ //# sourceMappingURL=tool-candidates.d.ts.map
@@ -0,0 +1,6 @@
1
+ /** Registered prefix matches for unknown-tool diagnostics, in catalog order, capped at eight.
2
+ * @complexity O(n) time and space over n registered tools. */
3
+ export function toolCandidates({ toolId, tools }, _optional = {}) {
4
+ return tools.map((tool) => tool.id).filter((id) => id.startsWith(toolId)).slice(0, 8);
5
+ }
6
+ //# sourceMappingURL=tool-candidates.js.map
@@ -33,8 +33,8 @@ export interface DelegatedToolInvocation {
33
33
  export interface DelegatedToolBridge {
34
34
  /**
35
35
  * Emits a canonical `tool_use`, executes through `ToolExecutor`, then emits
36
- * exactly one matching `tool_result`. Unknown tool ids remain programmer or
37
- * routing errors from `ToolExecutor` and are rethrown after their error
36
+ * exactly one matching `tool_result`. Unknown tool ids are input errors
37
+ * from `ToolExecutor` and are rethrown after their error
38
38
  * result is recorded.
39
39
  */
40
40
  execute(requiredArgs: Pick<DelegatedToolInvocation, "runId" | "toolUseId" | "toolId" | "principal" | "input">, optionalArgs?: Pick<DelegatedToolInvocation, "signal">): Promise<ToolExecutionResult>;
@@ -1,5 +1,5 @@
1
1
  import type { Express } from 'express';
2
- import type { Principal, ToolRegistry } from '@jini-ai/core';
2
+ import { type Principal, type ToolRegistry } from '@jini-ai/core';
3
3
  import { type RunLifecycle, type ToolExecutionResult, type ToolExecutor } from '../index.js';
4
4
  import { type AdapterContext } from '@jini-ai/http-kit';
5
5
  export interface DelegatedToolExecuteRequest {
@@ -81,7 +81,7 @@ export interface DelegatedToolsHttpDeps {
81
81
  /**
82
82
  * Host opt-in: the model-safe text to send in place of `'an internal error occurred'` for every
83
83
  * failure this route would otherwise answer with the SEC-005-redacted `500 INTERNAL_ERROR` — a
84
- * thrown executor (e.g. an unknown tool id), a throwing `resolvePrincipal`, `timed-out`,
84
+ * thrown executor infrastructure failure, a throwing `resolvePrincipal`, `timed-out`,
85
85
  * `cancelled`, and a `failed` result `isModelSafeToolFailure` did not vouch for. Without it, those
86
86
  * reach the model as a bare "INTERNAL_ERROR" it can neither act on nor report.
87
87
  *
@@ -98,8 +98,8 @@ export interface DelegatedToolsHttpDeps {
98
98
  * `POST /api/delegated-tool-calls` — executes one delegated tool call against an already-started
99
99
  * run. Maps `ToolExecutionResult.status` to the HTTP response via `toolExecutionResultToApiResult`
100
100
  * above — mirroring `db-ops.ts`'s precedent for the identical status union rather than treating
101
- * every business outcome as a `200`. A genuinely unexpected throw (an unregistered `toolId` — a
102
- * routing/programming error per `ToolExecutor.execute`'s own contract — or a race where the run
101
+ * every business outcome as a `200`. An unregistered `toolId` throws `ToolInputError` and answers
102
+ * `400 BAD_REQUEST`. A genuinely unexpected throw (such as a race where the run
103
103
  * became terminal between this route's existence check and the bridge's first emitted event)
104
104
  * reaches the same SEC-005 redaction path.
105
105
  */
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import { randomUUID } from 'node:crypto';
29
29
  import { createApiError } from '@jini-ai/protocol';
30
+ import { ToolInputError } from '@jini-ai/core';
30
31
  import { createDelegatedToolBridge } from '../index.js';
31
32
  import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
32
33
  import { validationError } from '@jini-ai/http-kit';
@@ -159,8 +160,8 @@ function toolExecutionResultToApiResult(deps, runId, toolId, result) {
159
160
  * `POST /api/delegated-tool-calls` — executes one delegated tool call against an already-started
160
161
  * run. Maps `ToolExecutionResult.status` to the HTTP response via `toolExecutionResultToApiResult`
161
162
  * above — mirroring `db-ops.ts`'s precedent for the identical status union rather than treating
162
- * every business outcome as a `200`. A genuinely unexpected throw (an unregistered `toolId` — a
163
- * routing/programming error per `ToolExecutor.execute`'s own contract — or a race where the run
163
+ * every business outcome as a `200`. An unregistered `toolId` throws `ToolInputError` and answers
164
+ * `400 BAD_REQUEST`. A genuinely unexpected throw (such as a race where the run
164
165
  * became terminal between this route's existence check and the bridge's first emitted event)
165
166
  * reaches the same SEC-005 redaction path.
166
167
  */
@@ -219,6 +220,9 @@ export const delegatedToolExecuteRoute = defineJsonRoute({
219
220
  return toolExecutionResultToApiResult(deps, input.runId, input.toolId, result);
220
221
  }
221
222
  catch (error) {
223
+ if (error instanceof ToolInputError) {
224
+ return err({ error: createApiError({ code: 'BAD_REQUEST', message: error.message }) });
225
+ }
222
226
  return err({ error: reportInternalError(deps, 'delegated-tool-execute', error, input.runId, input.toolId) });
223
227
  }
224
228
  }
@@ -83,7 +83,7 @@ export interface ToolExecutor {
83
83
  * @param emitSurface - Transport-supplied seam letting the handler push a human-only surface into
84
84
  * the run's event stream *before* this call settles. Omit for a caller with no run event stream;
85
85
  * see `@jini-ai/core`'s `SurfaceEmitter` for why a handler that waits on a human needs it.
86
- * @throws If `toolId` isn't registered — a routing/programming error, distinct from the denial/confirmation-denial states `ToolExecutionResult.status` covers.
86
+ * @throws {ToolInputError} If `toolId` isn't registered, with up to eight registered prefix matches.
87
87
  */
88
88
  execute(args: {
89
89
  readonly principal: Principal;
@@ -125,7 +125,7 @@ export interface CreateToolExecutorOptions {
125
125
  * — the one and only caller of that internal export.
126
126
  * @param options.delegate - Transport-supplied authorize/confirm UI seam;
127
127
  * omit for a headless caller whose tools never need interactive gating.
128
- * @complexity `execute` is O(1) plus the handler's own cost; `resumeConfirmation`/`cancel`/`getAuditRecord` are O(1) map lookups.
128
+ * @complexity Known-id `execute` is O(1) plus the handler's own cost; unknown ids scan O(n) descriptors for hints. `resumeConfirmation`/`cancel`/`getAuditRecord` are O(1) map lookups.
129
129
  * @overallScore 100/100
130
130
  */
131
131
  export declare function createToolExecutor(requiredArgs: Pick<CreateToolExecutorOptions, "registry">, optionalArgs?: Pick<CreateToolExecutorOptions, "delegate" | "now">): ToolExecutor;
@@ -48,6 +48,7 @@
48
48
  import { randomUUID } from 'node:crypto';
49
49
  import { ToolInputError, } from '@jini-ai/core';
50
50
  import { authorizeToolInvocation } from '@jini-ai/core/composition';
51
+ import { toolCandidates } from './core/tool-candidates.js';
51
52
  /**
52
53
  * Enforces `maxOutputBytes` in BYTES, which is what the option is named for.
53
54
  *
@@ -81,7 +82,7 @@ function truncateOutput(output, maxOutputBytes) {
81
82
  * — the one and only caller of that internal export.
82
83
  * @param options.delegate - Transport-supplied authorize/confirm UI seam;
83
84
  * omit for a headless caller whose tools never need interactive gating.
84
- * @complexity `execute` is O(1) plus the handler's own cost; `resumeConfirmation`/`cancel`/`getAuditRecord` are O(1) map lookups.
85
+ * @complexity Known-id `execute` is O(1) plus the handler's own cost; unknown ids scan O(n) descriptors for hints. `resumeConfirmation`/`cancel`/`getAuditRecord` are O(1) map lookups.
85
86
  * @overallScore 100/100
86
87
  */
87
88
  export function createToolExecutor(requiredArgs, optionalArgs = {}) {
@@ -182,7 +183,9 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
182
183
  // existence, never returns a handler, so this doesn't reopen the leak authorizeToolInvocation
183
184
  // closed — that gate still runs, still unconditionally, inside the call below.
184
185
  if (!registry.has({ toolId })) {
185
- throw new Error(`ToolExecutor: unknown tool "${toolId}"`);
186
+ const candidates = toolCandidates({ toolId, tools: registry.list({}) });
187
+ const hint = candidates.length > 0 ? `; did you mean ${candidates.join(', ')}?` : '';
188
+ throw new ToolInputError({ message: `ToolExecutor: unknown tool "${toolId}"${hint}` });
186
189
  }
187
190
  const executionId = openAudit(principal, run, toolId);
188
191
  // Observes `signal` for this execution's ENTIRE remaining lifetime, not just the handler-run
@@ -216,7 +219,7 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
216
219
  // Recorded terminal and returned as `failed` with a fixed message. `failed` is the right state:
217
220
  // this execution ended, and it ended for an operational reason rather than a decision. The real
218
221
  // exception stays server-side. Deliberately narrower than the `unknown tool` throw above, which
219
- // stays a throw because it is a routing/programming error, not an execution outcome.
222
+ // stays a typed input-error throw because no tool execution exists to settle.
220
223
  let resolved;
221
224
  try {
222
225
  resolved = await authorize(principal, run, toolId, input);
@@ -1,8 +1,8 @@
1
1
  // Local federation forks moved to @jini-ai/mcp/federation (+ /stdio, /approvals); see development/DELETED-CODE.md.
2
2
  /**
3
3
  * `ToolExecutor.execute`'s own contract for an unregistered id (`@jini-ai/daemon`'s
4
- * `tool-executor.ts`): a thrown `Error` reading `unknown tool "<id>"`, with no code or subclass to
5
- * match on instead. Duplicated from `tool-executor-audit.ts`'s identical `isUnknownToolError` rather
4
+ * `tool-executor.ts`): a thrown `ToolInputError` (from `@jini-ai/core`) reading `unknown tool "<id>"`.
5
+ * This still matches on the message text, not the class. Duplicated from `tool-executor-audit.ts`'s identical `isUnknownToolError` rather
6
6
  * than imported: that file is a sibling one layer up with no dependency on `mcp-federation`, and
7
7
  * importing across that line for an 18-character regex is not worth the coupling. Both copies carry
8
8
  * the same MEDIUM message-text-coupling note that file's own doc records — if `@jini-ai/daemon`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/daemon",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "The stateful agent daemon runtime: run lifecycle, durable event-log port, the tool-execution boundary, agent executor, terminal sessions, and routines.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -139,17 +139,17 @@
139
139
  "@types/pg": "8.23.1",
140
140
  "express": "^4.21.0",
141
141
  "@types/express": "^4.17.21",
142
- "@jini-ai/db": "0.3.4",
142
+ "@jini-ai/db": "0.3.5",
143
143
  "@jini-ai/http-kit": "0.4.2",
144
- "@jini-ai/platform": "^0.5.7",
145
- "@jini-ai/core": "^0.4.6",
146
144
  "@jini-ai/agent-runtime": "^0.4.11",
147
- "@jini-ai/chat": "0.6.3",
145
+ "@jini-ai/chat": "0.6.6",
146
+ "@jini-ai/platform": "^0.5.9",
147
+ "@jini-ai/core": "^0.4.7",
148
148
  "@jini-ai/protocol": "^0.4.4"
149
149
  },
150
150
  "dependencies": {
151
- "@jini-ai/protocol": "^0.4.4",
152
- "@jini-ai/core": "^0.4.6"
151
+ "@jini-ai/core": "^0.4.7",
152
+ "@jini-ai/protocol": "^0.4.4"
153
153
  },
154
154
  "peerDependencies": {
155
155
  "node-pty": "^1.1.0",