@jini-ai/daemon 0.6.8 → 0.6.9

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 (71) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/agent-executor/antigravity-permissions.d.ts +11 -0
  3. package/dist/agent-executor/antigravity-permissions.js +64 -0
  4. package/dist/agent-executor/contracts.d.ts +31 -6
  5. package/dist/agent-executor/event-translation.d.ts +6 -1
  6. package/dist/agent-executor/event-translation.js +1 -1
  7. package/dist/agent-executor/events.d.ts +1 -0
  8. package/dist/agent-executor/events.js +85 -74
  9. package/dist/agent-executor/launch.d.ts +4 -2
  10. package/dist/agent-executor/launch.js +306 -217
  11. package/dist/agent-executor/mcp-bridge-probe.js +51 -35
  12. package/dist/agent-executor/mcp-bridge.d.ts +18 -8
  13. package/dist/agent-executor/mcp-bridge.js +105 -65
  14. package/dist/agent-executor/run-output.d.ts +20 -0
  15. package/dist/agent-executor/run-output.js +80 -0
  16. package/dist/attachment-content.js +18 -15
  17. package/dist/core/read-only-tools.d.ts +15 -0
  18. package/dist/core/read-only-tools.js +29 -1
  19. package/dist/core/tool-audit.d.ts +3 -1
  20. package/dist/core/tool-audit.js +35 -22
  21. package/dist/core/tool-gaps.d.ts +60 -0
  22. package/dist/core/tool-gaps.js +199 -0
  23. package/dist/delegated-tool-bridge.d.ts +1 -1
  24. package/dist/delegated-tool-bridge.js +161 -122
  25. package/dist/event-log.d.ts +45 -2
  26. package/dist/event-log.js +83 -4
  27. package/dist/http/attachments.d.ts +26 -1
  28. package/dist/http/attachments.js +129 -65
  29. package/dist/http/connectors.d.ts +1 -32
  30. package/dist/http/connectors.js +13 -68
  31. package/dist/http/delegated-tools.d.ts +5 -1
  32. package/dist/http/delegated-tools.js +29 -29
  33. package/dist/http/host-tools.d.ts +1 -0
  34. package/dist/http/host-tools.js +64 -59
  35. package/dist/http/index.d.ts +2 -2
  36. package/dist/http/index.js +1 -1
  37. package/dist/http/media.js +48 -69
  38. package/dist/http/memory.js +45 -36
  39. package/dist/http/model-proxy-fields.d.ts +23 -0
  40. package/dist/http/model-proxy-fields.js +29 -0
  41. package/dist/http/model-proxy.js +193 -395
  42. package/dist/http/research-tavily.adapter.js +72 -49
  43. package/dist/http/research.js +33 -23
  44. package/dist/http/resource-request.d.ts +37 -0
  45. package/dist/http/resource-request.js +59 -0
  46. package/dist/http/routines.js +49 -90
  47. package/dist/http/runs.d.ts +7 -5
  48. package/dist/http/runs.js +21 -7
  49. package/dist/http/terminals.js +23 -48
  50. package/dist/http/tool-catalog.d.ts +5 -0
  51. package/dist/http/xai.d.ts +11 -1
  52. package/dist/http/xai.js +125 -124
  53. package/dist/legacy-data-migration.js +41 -24
  54. package/dist/run/core/retry.js +51 -35
  55. package/dist/run/diagnostics/diagnostics.js +5 -17
  56. package/dist/run-credentials.js +49 -27
  57. package/dist/run-lifecycle.d.ts +12 -2
  58. package/dist/run-lifecycle.js +47 -26
  59. package/dist/store/agent-sessions/sqlite.d.ts +6 -0
  60. package/dist/store/agent-sessions/sqlite.js +6 -4
  61. package/dist/surface-exchanges.js +2 -1
  62. package/dist/terminal-session.d.ts +1 -1
  63. package/dist/terminal-session.js +26 -33
  64. package/dist/tool-audit.d.ts +1 -0
  65. package/dist/tool-audit.js +1 -0
  66. package/dist/tool-executor.d.ts +24 -3
  67. package/dist/tool-executor.js +276 -139
  68. package/dist/tool-recovery/recovery.js +64 -35
  69. package/dist/tool-recovery/stack.d.ts +2 -1
  70. package/dist/tool-recovery/stack.js +1 -1
  71. package/package.json +10 -10
@@ -7,6 +7,74 @@ const TAVILY_MAX_RESULTS_LIMIT = 20;
7
7
  const DEFAULT_MAX_SOURCES = 5;
8
8
  /** Matches OD's own `tavily.ts` timeout — see research.ts's module doc. */
9
9
  const DEFAULT_TAVILY_TIMEOUT_MS = 30_000;
10
+ /**
11
+ * Marks an error already redacted and formatted by `fetchTavilySearch`'s `!response.ok` branch, so
12
+ * `toTavilyFailure` can rethrow it verbatim instead of wrapping it a second time (which would
13
+ * otherwise produce a "Tavily request failed: Tavily 429: ..." double-prefixed message).
14
+ */
15
+ class TavilyHttpError extends Error {
16
+ }
17
+ function resolveTavilyBase(credentials) {
18
+ const baseUrl = credentials.baseUrl ?? DEFAULT_TAVILY_BASE_URL;
19
+ const baseUrlCheck = validateBaseUrl({ baseUrl });
20
+ if (baseUrlCheck.error) {
21
+ throw new Error(baseUrlCheck.error);
22
+ }
23
+ return baseUrl.replace(/\/+$/, '');
24
+ }
25
+ function trimmedOrUndefined(value) {
26
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
27
+ }
28
+ /** One raw Tavily result as a source, or `null` when it carries no usable URL. */
29
+ function toResearchSource(raw) {
30
+ const url = typeof raw.url === 'string' ? raw.url : '';
31
+ if (!url)
32
+ return null;
33
+ const publishedAt = trimmedOrUndefined(raw.published_date);
34
+ return {
35
+ title: trimmedOrUndefined(raw.title) ?? url,
36
+ url,
37
+ snippet: typeof raw.content === 'string' ? raw.content.trim().slice(0, 800) : '',
38
+ provider: 'tavily',
39
+ ...(publishedAt ? { publishedAt } : {}),
40
+ };
41
+ }
42
+ function parseTavilyResponse(json) {
43
+ const answer = typeof json.answer === 'string' ? json.answer : '';
44
+ const rawResults = Array.isArray(json.results) ? json.results : [];
45
+ const sources = [];
46
+ for (const raw of rawResults) {
47
+ const source = toResearchSource(raw);
48
+ if (source)
49
+ sources.push(source);
50
+ }
51
+ return { answer, sources };
52
+ }
53
+ async function fetchTavilySearch({ fetchImpl, url, apiKey, body, signal }) {
54
+ const response = await fetchImpl(url, {
55
+ method: 'POST',
56
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
57
+ body: JSON.stringify(body),
58
+ signal,
59
+ });
60
+ if (!response.ok) {
61
+ const text = await response.text().catch(() => '');
62
+ throw new TavilyHttpError(redactSecrets({ input: `Tavily ${response.status}: ${text.slice(0, 200) || 'no body'}` }, { exactSecrets: [apiKey] }));
63
+ }
64
+ return parseTavilyResponse((await response.json()));
65
+ }
66
+ /** The redacted error `tavilySearch` throws for any failure: a timeout, an already-formatted HTTP error, or anything else. */
67
+ function toTavilyFailure(error, { apiKey, timedOut }) {
68
+ if (!timedOut && error instanceof TavilyHttpError) {
69
+ return error;
70
+ }
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ return new Error(redactSecrets({
73
+ input: timedOut
74
+ ? `Tavily request timed out after ${DEFAULT_TAVILY_TIMEOUT_MS}ms`
75
+ : `Tavily request failed: ${message}`,
76
+ }, { exactSecrets: [apiKey] }));
77
+ }
10
78
  /**
11
79
  * Calls Tavily's real `POST /search` endpoint directly — see research.ts's module doc for the port's exact
12
80
  * scope/provenance. Throws (never a `Result`) on any failure; the route handler in research.ts is the one
@@ -20,12 +88,7 @@ const DEFAULT_TAVILY_TIMEOUT_MS = 30_000;
20
88
  * `credentials` is still passed for its optional transport config (`baseUrl`).
21
89
  */
22
90
  async function tavilySearch({ apiKey, credentials, request }, { fetch: fetchImpl = globalThis.fetch } = {}) {
23
- const baseUrl = credentials.baseUrl ?? DEFAULT_TAVILY_BASE_URL;
24
- const baseUrlCheck = validateBaseUrl({ baseUrl });
25
- if (baseUrlCheck.error) {
26
- throw new Error(baseUrlCheck.error);
27
- }
28
- const base = baseUrl.replace(/\/+$/, '');
91
+ const base = resolveTavilyBase(credentials);
29
92
  // Floor before clamping, matching OD's real `clampMaxSources` — see research.ts's module doc. Without the
30
93
  // floor, a fractional `maxSources` (e.g. `2.7`, or `0.5` before the `Math.max(1, ...)` floor
31
94
  // below) would forward a non-integer `max_results` straight to Tavily's real API.
@@ -42,12 +105,7 @@ async function tavilySearch({ apiKey, credentials, request }, { fetch: fetchImpl
42
105
  // external audit caught (and locally reproduced) an earlier version of this fix that cleared
43
106
  // the timer right after `fetch` resolved, leaving `response.text()`/`response.json()` below
44
107
  // completely unbounded against a server that sends headers promptly and then stalls the body.
45
- //
46
- // `TavilyHttpError` marks an error already redacted and formatted by the `!response.ok` branch
47
- // below, so the outer catch can rethrow it verbatim instead of wrapping it a second time (which
48
- // would otherwise produce a "Tavily request failed: Tavily 429: ..." double-prefixed message).
49
- class TavilyHttpError extends Error {
50
- }
108
+ // `fetchTavilySearch` therefore reads the body inside the same armed window.
51
109
  const timeoutController = new AbortController();
52
110
  const timeoutHandle = setTimeout(() => timeoutController.abort(), DEFAULT_TAVILY_TIMEOUT_MS);
53
111
  // Assigned in the `try` and returned *after* the `finally`, rather than returned from inside the
@@ -57,45 +115,10 @@ async function tavilySearch({ apiKey, credentials, request }, { fetch: fetchImpl
57
115
  // keeps the cleanup path honestly measurable instead of permanently one-third uncovered.
58
116
  let output;
59
117
  try {
60
- const response = await fetchImpl(`${base}/search`, {
61
- method: 'POST',
62
- headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
63
- body: JSON.stringify(body),
64
- signal: timeoutController.signal,
65
- });
66
- if (!response.ok) {
67
- const text = await response.text().catch(() => '');
68
- throw new TavilyHttpError(redactSecrets({ input: `Tavily ${response.status}: ${text.slice(0, 200) || 'no body'}` }, { exactSecrets: [apiKey] }));
69
- }
70
- const json = (await response.json());
71
- const answer = typeof json.answer === 'string' ? json.answer : '';
72
- const rawResults = Array.isArray(json.results) ? json.results : [];
73
- const sources = [];
74
- for (const raw of rawResults) {
75
- const url = typeof raw.url === 'string' ? raw.url : '';
76
- if (!url)
77
- continue;
78
- const publishedAt = typeof raw.published_date === 'string' && raw.published_date.trim() ? raw.published_date.trim() : undefined;
79
- sources.push({
80
- title: typeof raw.title === 'string' && raw.title.trim() ? raw.title.trim() : url,
81
- url,
82
- snippet: typeof raw.content === 'string' ? raw.content.trim().slice(0, 800) : '',
83
- provider: 'tavily',
84
- ...(publishedAt ? { publishedAt } : {}),
85
- });
86
- }
87
- output = { answer, sources };
118
+ output = await fetchTavilySearch({ fetchImpl, url: `${base}/search`, apiKey, body, signal: timeoutController.signal });
88
119
  }
89
120
  catch (error) {
90
- if (!timeoutController.signal.aborted && error instanceof TavilyHttpError) {
91
- throw error;
92
- }
93
- const message = error instanceof Error ? error.message : String(error);
94
- throw new Error(redactSecrets({
95
- input: timeoutController.signal.aborted
96
- ? `Tavily request timed out after ${DEFAULT_TAVILY_TIMEOUT_MS}ms`
97
- : `Tavily request failed: ${message}`,
98
- }, { exactSecrets: [apiKey] }));
121
+ throw toTavilyFailure(error, { apiKey, timedOut: timeoutController.signal.aborted });
99
122
  }
100
123
  finally {
101
124
  clearTimeout(timeoutHandle);
@@ -98,9 +98,7 @@ import { redactSecrets } from '@jini-ai/core';
98
98
  import { randomUUID } from 'node:crypto';
99
99
  import { createTavilyResearchProvider } from './research-tavily.adapter.js';
100
100
  import { createApiError } from '@jini-ai/protocol';
101
- import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
102
- import { validationError } from '@jini-ai/http-kit';
103
- import { err, ok } from '@jini-ai/http-kit';
101
+ import { defineJsonRoute, err, mountJsonRoute, ok, validationError } from '@jini-ai/http-kit';
104
102
  /** Matches OD's real `research/index.ts#searchResearch` query cap — see module doc. */
105
103
  const MAX_QUERY_LENGTH = 1000;
106
104
  function defaultInternalErrorSink(context) {
@@ -142,7 +140,7 @@ function validateRequestedProvider(rawProviders, providerId) {
142
140
  }
143
141
  return ok({ value: null });
144
142
  }
145
- function parseResearchSearch(input, providerId = 'tavily') {
143
+ function parseResearchSearch(input, providerId) {
146
144
  if (!isRecord(input.body))
147
145
  return err({ error: validationError({ message: 'body must be a JSON object' }) });
148
146
  const rawQuery = nonEmptyString(input.body.query);
@@ -166,22 +164,45 @@ function synthesizeFallbackSummary(sources) {
166
164
  .join('\n');
167
165
  return `(No provider summary; top snippets follow.)\n${lead}`;
168
166
  }
167
+ /** The route-bound provider, else the host's `deps.provider`, else the built-in Tavily transport over `deps.fetch`. */
168
+ function selectProvider(bound, deps) {
169
+ return bound ?? deps.provider ?? createTavilyResearchProvider({}, deps.fetch ? { fetch: deps.fetch } : {});
170
+ }
171
+ /** The host's resolver, else `TAVILY_API_KEY` for a `'tavily'` provider and no credentials for any other. */
172
+ function credentialResolver(provider, deps) {
173
+ return deps.resolveCredentials ?? (() => provider.id === 'tavily' ? defaultResolveCredentials({ env: deps.env ?? process.env }) : Promise.resolve({}));
174
+ }
175
+ /** Reports `error` to the host sink under a fresh correlation id and returns the generic SEC-005 public error. */
176
+ function reportInternalError(deps, error) {
177
+ const correlationId = randomUUID();
178
+ (deps.onInternalError ?? defaultInternalErrorSink)({ correlationId, error });
179
+ return createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId });
180
+ }
181
+ /** A copy of a provider failure with `apiKey` redacted from its message, name and stack, safe for the error sink. */
182
+ function redactProviderError(error, apiKey) {
183
+ const redact = (input) => redactSecrets({ input }, { exactSecrets: [apiKey] });
184
+ const safeError = new Error(redact(error instanceof Error ? error.message : String(error)));
185
+ if (error instanceof Error) {
186
+ safeError.name = redact(error.name);
187
+ if (error.stack === undefined)
188
+ delete safeError.stack;
189
+ else
190
+ safeError.stack = redact(error.stack);
191
+ }
192
+ return safeError;
193
+ }
169
194
  /** Builds one route over a host-selected provider, preserving the default Tavily validation contract. */
170
195
  export function createResearchSearchRoute({ provider }, _optional = {}) {
171
196
  const providerId = provider?.id ?? 'tavily';
172
197
  return defineJsonRoute({
173
198
  method: 'post', path: '/api/research/search', parse: (input) => parseResearchSearch(input, providerId), handle: async ({ input: request, deps }) => {
174
- const selectedProvider = provider ?? deps.provider ?? createTavilyResearchProvider({}, deps.fetch ? { fetch: deps.fetch } : {});
175
- const resolveCredentials = deps.resolveCredentials ?? (() => selectedProvider.id === 'tavily' ? defaultResolveCredentials({ env: deps.env ?? process.env }) : Promise.resolve({}));
176
- const onInternalError = deps.onInternalError ?? defaultInternalErrorSink;
199
+ const selectedProvider = selectProvider(provider, deps);
177
200
  let credentials;
178
201
  try {
179
- credentials = await resolveCredentials({ providerId: selectedProvider.id });
202
+ credentials = await credentialResolver(selectedProvider, deps)({ providerId: selectedProvider.id });
180
203
  }
181
204
  catch (error) {
182
- const correlationId = randomUUID();
183
- onInternalError({ correlationId, error });
184
- return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
205
+ return err({ error: reportInternalError(deps, error) });
185
206
  }
186
207
  const apiKey = credentials.apiKey;
187
208
  if (!apiKey) {
@@ -205,19 +226,8 @@ export function createResearchSearchRoute({ provider }, _optional = {}) {
205
226
  return ok({ value: result });
206
227
  }
207
228
  catch (error) {
208
- const correlationId = randomUUID();
209
229
  // A host provider can throw transport text too; keep its configured key out of the sink.
210
- const safeMessage = redactSecrets({ input: error instanceof Error ? error.message : String(error) }, { exactSecrets: [apiKey] });
211
- const safeError = new Error(safeMessage);
212
- if (error instanceof Error) {
213
- safeError.name = redactSecrets({ input: error.name }, { exactSecrets: [apiKey] });
214
- if (error.stack === undefined)
215
- delete safeError.stack;
216
- else
217
- safeError.stack = redactSecrets({ input: error.stack }, { exactSecrets: [apiKey] });
218
- }
219
- onInternalError({ correlationId, error: safeError });
220
- return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
230
+ return err({ error: reportInternalError(deps, redactProviderError(error, apiKey)) });
221
231
  }
222
232
  }
223
233
  }, { requireSameOrigin: true });
@@ -0,0 +1,37 @@
1
+ import { type Result } from '@jini-ai/http-kit';
2
+ import { type WorkspaceRootResolver } from './workspace-root.js';
3
+ /** A plain JSON object body (not `null`, not an array). */
4
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
5
+ /** Defined-only view of `T`: every key optional, and present only when its value is not `undefined`. */
6
+ export type DefinedFields<T> = {
7
+ [K in keyof T]?: Exclude<T[K], undefined>;
8
+ };
9
+ /**
10
+ * Copies `fields` without its `undefined`-valued keys, keeping key order — the
11
+ * `...(x === undefined ? {} : { x })` spread pattern for many fields at once, so an absent
12
+ * optional field is omitted from the result rather than carried as `undefined`.
13
+ */
14
+ export declare function withoutUndefined<T extends Record<string, unknown>>(fields: T): DefinedFields<T>;
15
+ /** Reads an optional body field that, when present, must be a non-empty string; refuses with `<field> must be a non-empty string when provided`. */
16
+ export declare function readOptionalNonEmptyString({ body, field }: {
17
+ readonly body: Readonly<Record<string, unknown>>;
18
+ readonly field: string;
19
+ }): Result<string | undefined>;
20
+ /** Reads an optional body field that, when present, must be `typeof 'number'` (NaN/Infinity included — see the terminals pin tests); refuses with `<field> must be a number when provided`. */
21
+ export declare function readOptionalNumber({ body, field }: {
22
+ readonly body: Readonly<Record<string, unknown>>;
23
+ readonly field: string;
24
+ }): Result<number | undefined>;
25
+ /**
26
+ * Resolves `resourceRef`/`detail` to a working directory through `resolver`. A denial
27
+ * ({@link WorkspaceRootDeniedError}, which the zero-config `denyAllWorkspaceRoots` default always
28
+ * produces) becomes a generic `NOT_FOUND`; any other resolver failure is rethrown unchanged so the
29
+ * transport's internal-error path handles it rather than disguising it as a missing resource.
30
+ */
31
+ export declare function resolveResourceRoot({ resourceRef, detail }: {
32
+ readonly resourceRef: string;
33
+ readonly detail?: string | undefined;
34
+ }, { resolver }: {
35
+ readonly resolver: WorkspaceRootResolver;
36
+ }): Promise<Result<string>>;
37
+ //# sourceMappingURL=resource-request.d.ts.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @module resource-request
3
+ *
4
+ * Request-parsing and workspace-root helpers shared by the route packs that turn a caller's
5
+ * `resourceRef`/`detail` into a working directory: `host-tools.ts`'s open-in route and
6
+ * `terminals.ts`'s create route. Both previously inlined the same `detail` validation and the
7
+ * same "resolve the root, map a {@link WorkspaceRootDeniedError} to `NOT_FOUND`, rethrow anything
8
+ * else" block; this file is that one copy. Not re-exported from the package barrel.
9
+ */
10
+ import { createApiError } from '@jini-ai/protocol';
11
+ import { err, ok, validationError } from '@jini-ai/http-kit';
12
+ import { resolveWorkspaceRoot, WorkspaceRootDeniedError } from './workspace-root.js';
13
+ /** A plain JSON object body (not `null`, not an array). */
14
+ export function isRecord(value) {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ }
17
+ /**
18
+ * Copies `fields` without its `undefined`-valued keys, keeping key order — the
19
+ * `...(x === undefined ? {} : { x })` spread pattern for many fields at once, so an absent
20
+ * optional field is omitted from the result rather than carried as `undefined`.
21
+ */
22
+ export function withoutUndefined(fields) {
23
+ return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
24
+ }
25
+ /** Reads an optional body field that, when present, must be a non-empty string; refuses with `<field> must be a non-empty string when provided`. */
26
+ export function readOptionalNonEmptyString({ body, field }) {
27
+ const value = body[field];
28
+ if (value === undefined)
29
+ return ok({ value: undefined });
30
+ if (typeof value !== 'string' || value.length === 0) {
31
+ return err({ error: validationError({ message: `${field} must be a non-empty string when provided` }) });
32
+ }
33
+ return ok({ value });
34
+ }
35
+ /** Reads an optional body field that, when present, must be `typeof 'number'` (NaN/Infinity included — see the terminals pin tests); refuses with `<field> must be a number when provided`. */
36
+ export function readOptionalNumber({ body, field }) {
37
+ const value = body[field];
38
+ if (value === undefined || typeof value === 'number')
39
+ return ok({ value });
40
+ return err({ error: validationError({ message: `${field} must be a number when provided` }) });
41
+ }
42
+ /**
43
+ * Resolves `resourceRef`/`detail` to a working directory through `resolver`. A denial
44
+ * ({@link WorkspaceRootDeniedError}, which the zero-config `denyAllWorkspaceRoots` default always
45
+ * produces) becomes a generic `NOT_FOUND`; any other resolver failure is rethrown unchanged so the
46
+ * transport's internal-error path handles it rather than disguising it as a missing resource.
47
+ */
48
+ export async function resolveResourceRoot({ resourceRef, detail }, { resolver }) {
49
+ try {
50
+ return ok({ value: await resolveWorkspaceRoot({ request: { resourceRef, ...withoutUndefined({ detail }) } }, { resolver }) });
51
+ }
52
+ catch (error) {
53
+ if (error instanceof WorkspaceRootDeniedError) {
54
+ return err({ error: createApiError({ code: 'NOT_FOUND', message: `resource "${resourceRef}" was not found` }) });
55
+ }
56
+ throw error;
57
+ }
58
+ }
59
+ //# sourceMappingURL=resource-request.js.map
@@ -96,46 +96,52 @@ function parseContextSelection(value) {
96
96
  }
97
97
  return ok({ value: context });
98
98
  }
99
+ function parseOptionalBoolean(body, field) {
100
+ const value = body[field];
101
+ if (value !== undefined && typeof value !== 'boolean') {
102
+ return err({ error: validationError({ message: `${field} must be a boolean when provided` }) });
103
+ }
104
+ return ok({ value });
105
+ }
106
+ /** Table order is the validation order shared by create and update: the first failing field is the one a caller sees. */
107
+ const ROUTINE_FIELD_PARSERS = [
108
+ ['name', (body) => parseNonEmptyString(body.name, 'name')],
109
+ ['prompt', (body) => parseNonEmptyString(body.prompt, 'prompt')],
110
+ ['schedule', (body) => parseSchedule(body.schedule)],
111
+ ['target', (body) => parseTarget(body.target)],
112
+ ['skillId', (body) => parseOptionalNullableString(body, 'skillId')],
113
+ ['agentId', (body) => parseOptionalNullableString(body, 'agentId')],
114
+ ['context', (body) => parseContextSelection(body.context)],
115
+ ['enabled', (body) => parseOptionalBoolean(body, 'enabled')],
116
+ ];
117
+ /**
118
+ * Parses every routine field in table order, omitting any that parse to `undefined`. With
119
+ * `onlyProvided` (update), a field absent from the body is skipped before parsing, so required
120
+ * fields are not demanded and an absent `context` is not defaulted to `{}`.
121
+ */
122
+ function parseRoutineFields({ body }, { onlyProvided = false } = {}) {
123
+ // `RoutineCreateInput`/`RoutineUpdateInput`'s fields are `readonly` (an immutable port-input
124
+ // contract) — built here as a mutable local shape field-by-field, then handed back as that
125
+ // readonly type, which is a safe widening (mutable -> readonly), not the reverse.
126
+ const fields = {};
127
+ for (const [key, parse] of ROUTINE_FIELD_PARSERS) {
128
+ if (onlyProvided && body[key] === undefined)
129
+ continue;
130
+ const parsed = parse(body);
131
+ if (!parsed.ok)
132
+ return parsed;
133
+ if (parsed.value !== undefined)
134
+ fields[key] = parsed.value;
135
+ }
136
+ return ok({ value: fields });
137
+ }
99
138
  function parseRoutineCreate(input) {
100
139
  if (!isRecord(input.body))
101
140
  return err({ error: validationError({ message: 'body must be a JSON object' }) });
102
- const name = parseNonEmptyString(input.body.name, 'name');
103
- if (!name.ok)
104
- return name;
105
- const prompt = parseNonEmptyString(input.body.prompt, 'prompt');
106
- if (!prompt.ok)
107
- return prompt;
108
- const schedule = parseSchedule(input.body.schedule);
109
- if (!schedule.ok)
110
- return schedule;
111
- const target = parseTarget(input.body.target);
112
- if (!target.ok)
113
- return target;
114
- const skillId = parseOptionalNullableString(input.body, 'skillId');
115
- if (!skillId.ok)
116
- return skillId;
117
- const agentId = parseOptionalNullableString(input.body, 'agentId');
118
- if (!agentId.ok)
119
- return agentId;
120
- const context = parseContextSelection(input.body.context);
121
- if (!context.ok)
122
- return context;
123
- const enabled = input.body.enabled;
124
- if (enabled !== undefined && typeof enabled !== 'boolean') {
125
- return err({ error: validationError({ message: 'enabled must be a boolean when provided' }) });
126
- }
127
- return ok({
128
- value: {
129
- name: name.value,
130
- prompt: prompt.value,
131
- schedule: schedule.value,
132
- target: target.value,
133
- ...(skillId.value !== undefined ? { skillId: skillId.value } : {}),
134
- ...(agentId.value !== undefined ? { agentId: agentId.value } : {}),
135
- context: context.value,
136
- ...(enabled !== undefined ? { enabled } : {}),
137
- }
138
- });
141
+ const fields = parseRoutineFields({ body: input.body });
142
+ if (!fields.ok)
143
+ return fields;
144
+ return ok({ value: fields.value });
139
145
  }
140
146
  function parseRoutineUpdate(input) {
141
147
  const idResult = parseRoutineId(input);
@@ -143,59 +149,12 @@ function parseRoutineUpdate(input) {
143
149
  return idResult;
144
150
  if (!isRecord(input.body))
145
151
  return err({ error: validationError({ message: 'body must be a JSON object' }) });
146
- const body = input.body;
147
- // `RoutineUpdateInput`'s fields are `readonly` (an immutable port-input contract) — built here
148
- // as a mutable local shape field-by-field, then handed back as that readonly type, which is a
149
- // safe widening (mutable -> readonly), not the reverse.
150
- const patch = {};
151
- if (body.name !== undefined) {
152
- const name = parseNonEmptyString(body.name, 'name');
153
- if (!name.ok)
154
- return name;
155
- patch.name = name.value;
156
- }
157
- if (body.prompt !== undefined) {
158
- const prompt = parseNonEmptyString(body.prompt, 'prompt');
159
- if (!prompt.ok)
160
- return prompt;
161
- patch.prompt = prompt.value;
162
- }
163
- if (body.schedule !== undefined) {
164
- const schedule = parseSchedule(body.schedule);
165
- if (!schedule.ok)
166
- return schedule;
167
- patch.schedule = schedule.value;
168
- }
169
- if (body.target !== undefined) {
170
- const target = parseTarget(body.target);
171
- if (!target.ok)
172
- return target;
173
- patch.target = target.value;
174
- }
175
- if (body.skillId !== undefined) {
176
- const skillId = parseOptionalNullableString(body, 'skillId');
177
- if (!skillId.ok)
178
- return skillId;
179
- patch.skillId = skillId.value ?? null;
180
- }
181
- if (body.agentId !== undefined) {
182
- const agentId = parseOptionalNullableString(body, 'agentId');
183
- if (!agentId.ok)
184
- return agentId;
185
- patch.agentId = agentId.value ?? null;
186
- }
187
- if (body.context !== undefined) {
188
- const context = parseContextSelection(body.context);
189
- if (!context.ok)
190
- return context;
191
- patch.context = context.value;
192
- }
193
- if (body.enabled !== undefined) {
194
- if (typeof body.enabled !== 'boolean')
195
- return err({ error: validationError({ message: 'enabled must be a boolean when provided' }) });
196
- patch.enabled = body.enabled;
197
- }
198
- return ok({ value: { id: idResult.value, patch } });
152
+ // A provided `skillId`/`agentId` parses to a string or `null` (never `undefined`), so an explicit
153
+ // `null` reaches the patch and clears the field.
154
+ const patch = parseRoutineFields({ body: input.body }, { onlyProvided: true });
155
+ if (!patch.ok)
156
+ return patch;
157
+ return ok({ value: { id: idResult.value, patch: patch.value } });
199
158
  }
200
159
  function parseRoutineRunsQuery(input) {
201
160
  const idResult = parseRoutineId(input);
@@ -37,12 +37,12 @@ export interface RunHttpDeps {
37
37
  /**
38
38
  * Delivers a message the human sent mid-run into that run's live agent — a host passes its
39
39
  * `AgentExecutor.sendUserMessage`. Omitted, every such message reports `'unsupported'`, so the
40
- * client falls back to interrupting the run and sending the text as the next turn.
40
+ * client falls back to interrupting the run and sending the text as the next turn. Attachment
41
+ * turns use the explicit hook below, or report unsupported without sending anything.
41
42
  */
42
- readonly deliverUserMessage?: (input: {
43
- readonly runId: string;
44
- readonly text: string;
45
- }) => UserMessageDelivery | Promise<UserMessageDelivery>;
43
+ readonly deliverUserMessage?: (input: RunMessageRequest) => UserMessageDelivery | Promise<UserMessageDelivery>;
44
+ /** Explicit opt-in: legacy text-only hosts must never acknowledge a turn while discarding its files. */
45
+ readonly deliverUserMessageWithAttachments?: (input: RunMessageRequest) => UserMessageDelivery | Promise<UserMessageDelivery>;
46
46
  }
47
47
  export interface RunStartResponse {
48
48
  readonly run: RunStatus;
@@ -57,6 +57,8 @@ export interface RunCancelResponse {
57
57
  export interface RunMessageRequest {
58
58
  readonly runId: string;
59
59
  readonly text: string;
60
+ /** Opaque upload capabilities; the host must authorize and prepare every ref before delivery. */
61
+ readonly attachmentIds?: readonly string[];
60
62
  }
61
63
  export interface RunMessageResponse {
62
64
  readonly delivery: 'delivered';
package/dist/http/runs.js CHANGED
@@ -5,11 +5,7 @@
5
5
  */
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { createApiError } from '@jini-ai/protocol';
8
- import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
9
- import { validationError } from '@jini-ai/http-kit';
10
- import { sendApiError } from '@jini-ai/http-kit';
11
- import { createSseChannel, requestedAfterCursor, sendRawApiError } from '@jini-ai/http-kit';
12
- import { err, ok } from '@jini-ai/http-kit';
8
+ import { createSseChannel, defineJsonRoute, err, mountJsonRoute, ok, requestedAfterCursor, sendApiError, sendRawApiError, validationError, } from '@jini-ai/http-kit';
13
9
  /** Default sink when a host does not supply `onInternalError`: still observable, never silent. */
14
10
  function defaultInternalErrorSink(context) {
15
11
  // eslint-disable-next-line no-console
@@ -59,6 +55,20 @@ function parseRunCreate(input) {
59
55
  }
60
56
  /** Ceiling on one mid-run message, matching what a person can reasonably type or paste into a composer. */
61
57
  export const RUN_MESSAGE_MAX_CHARS = 100_000;
58
+ /** Most attachment refs one mid-run message may carry. */
59
+ const MAX_ATTACHMENTS_PER_MESSAGE = 10;
60
+ /** An opaque upload capability: `attachment:` then 1-128 word characters or dashes, nothing path-shaped. */
61
+ function isAttachmentRef(ref) {
62
+ return typeof ref === 'string' && /^attachment:[\w-]{1,128}$/u.test(ref);
63
+ }
64
+ /** `attachmentIds` is optional; when present it must be an array of at most ten well-formed refs, or the whole message is refused. */
65
+ function parseAttachmentIds(value) {
66
+ if (value === undefined)
67
+ return ok({ value: undefined });
68
+ if (Array.isArray(value) && value.length <= MAX_ATTACHMENTS_PER_MESSAGE && value.every(isAttachmentRef))
69
+ return ok({ value });
70
+ return err({ error: validationError({ message: 'attachmentIds must contain at most 10 opaque attachment refs' }) });
71
+ }
62
72
  function parseRunMessage(input) {
63
73
  const parsedRunId = parseRunId(input);
64
74
  if (!parsedRunId.ok)
@@ -72,7 +82,10 @@ function parseRunMessage(input) {
72
82
  if (text.length > RUN_MESSAGE_MAX_CHARS) {
73
83
  return err({ error: validationError({ message: `text must be at most ${RUN_MESSAGE_MAX_CHARS} characters` }, { issues: [{ path: 'text', message: 'too long' }] }) });
74
84
  }
75
- return ok({ value: { runId: parsedRunId.value, text } });
85
+ const attachmentIds = parseAttachmentIds(input.body.attachmentIds);
86
+ if (!attachmentIds.ok)
87
+ return attachmentIds;
88
+ return ok({ value: { runId: parsedRunId.value, text, ...(attachmentIds.value === undefined ? {} : { attachmentIds: attachmentIds.value }) } });
76
89
  }
77
90
  function parseRunList(input) {
78
91
  const value = input.query.contextRef;
@@ -149,7 +162,8 @@ export const runMessageRoute = defineJsonRoute({
149
162
  const run = await deps.lifecycle.get({ runId: input.runId });
150
163
  if (run === undefined)
151
164
  return err({ error: createApiError({ code: 'NOT_FOUND', message: `run "${input.runId}" was not found` }) });
152
- const delivery = deps.deliverUserMessage ? await deps.deliverUserMessage({ runId: input.runId, text: input.text }) : 'unsupported';
165
+ const deliver = input.attachmentIds?.length ? deps.deliverUserMessageWithAttachments : deps.deliverUserMessage;
166
+ const delivery = deliver ? await deliver(input) : 'unsupported';
153
167
  if (delivery === 'delivered')
154
168
  return ok({ value: { delivery } });
155
169
  return err({ error: createApiError({ code: 'CONFLICT', message: `run "${input.runId}" did not take the message` }, { details: { delivery } }) });