@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.
- package/CHANGELOG.md +4 -0
- package/dist/agent-executor/antigravity-permissions.d.ts +11 -0
- package/dist/agent-executor/antigravity-permissions.js +64 -0
- package/dist/agent-executor/contracts.d.ts +31 -6
- package/dist/agent-executor/event-translation.d.ts +6 -1
- package/dist/agent-executor/event-translation.js +1 -1
- package/dist/agent-executor/events.d.ts +1 -0
- package/dist/agent-executor/events.js +85 -74
- package/dist/agent-executor/launch.d.ts +4 -2
- package/dist/agent-executor/launch.js +306 -217
- package/dist/agent-executor/mcp-bridge-probe.js +51 -35
- package/dist/agent-executor/mcp-bridge.d.ts +18 -8
- package/dist/agent-executor/mcp-bridge.js +105 -65
- package/dist/agent-executor/run-output.d.ts +20 -0
- package/dist/agent-executor/run-output.js +80 -0
- package/dist/attachment-content.js +18 -15
- package/dist/core/read-only-tools.d.ts +15 -0
- package/dist/core/read-only-tools.js +29 -1
- package/dist/core/tool-audit.d.ts +3 -1
- package/dist/core/tool-audit.js +35 -22
- package/dist/core/tool-gaps.d.ts +60 -0
- package/dist/core/tool-gaps.js +199 -0
- package/dist/delegated-tool-bridge.d.ts +1 -1
- package/dist/delegated-tool-bridge.js +161 -122
- package/dist/event-log.d.ts +45 -2
- package/dist/event-log.js +83 -4
- package/dist/http/attachments.d.ts +26 -1
- package/dist/http/attachments.js +129 -65
- package/dist/http/connectors.d.ts +1 -32
- package/dist/http/connectors.js +13 -68
- package/dist/http/delegated-tools.d.ts +5 -1
- package/dist/http/delegated-tools.js +29 -29
- package/dist/http/host-tools.d.ts +1 -0
- package/dist/http/host-tools.js +64 -59
- package/dist/http/index.d.ts +2 -2
- package/dist/http/index.js +1 -1
- package/dist/http/media.js +48 -69
- package/dist/http/memory.js +45 -36
- package/dist/http/model-proxy-fields.d.ts +23 -0
- package/dist/http/model-proxy-fields.js +29 -0
- package/dist/http/model-proxy.js +193 -395
- package/dist/http/research-tavily.adapter.js +72 -49
- package/dist/http/research.js +33 -23
- package/dist/http/resource-request.d.ts +37 -0
- package/dist/http/resource-request.js +59 -0
- package/dist/http/routines.js +49 -90
- package/dist/http/runs.d.ts +7 -5
- package/dist/http/runs.js +21 -7
- package/dist/http/terminals.js +23 -48
- package/dist/http/tool-catalog.d.ts +5 -0
- package/dist/http/xai.d.ts +11 -1
- package/dist/http/xai.js +125 -124
- package/dist/legacy-data-migration.js +41 -24
- package/dist/run/core/retry.js +51 -35
- package/dist/run/diagnostics/diagnostics.js +5 -17
- package/dist/run-credentials.js +49 -27
- package/dist/run-lifecycle.d.ts +12 -2
- package/dist/run-lifecycle.js +47 -26
- package/dist/store/agent-sessions/sqlite.d.ts +6 -0
- package/dist/store/agent-sessions/sqlite.js +6 -4
- package/dist/surface-exchanges.js +2 -1
- package/dist/terminal-session.d.ts +1 -1
- package/dist/terminal-session.js +26 -33
- package/dist/tool-audit.d.ts +1 -0
- package/dist/tool-audit.js +1 -0
- package/dist/tool-executor.d.ts +24 -3
- package/dist/tool-executor.js +276 -139
- package/dist/tool-recovery/recovery.js +64 -35
- package/dist/tool-recovery/stack.d.ts +2 -1
- package/dist/tool-recovery/stack.js +1 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/http/research.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
202
|
+
credentials = await credentialResolver(selectedProvider, deps)({ providerId: selectedProvider.id });
|
|
180
203
|
}
|
|
181
204
|
catch (error) {
|
|
182
|
-
|
|
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
|
-
|
|
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
|
package/dist/http/routines.js
CHANGED
|
@@ -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
|
|
103
|
-
if (!
|
|
104
|
-
return
|
|
105
|
-
|
|
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
|
-
|
|
147
|
-
// `
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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);
|
package/dist/http/runs.d.ts
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
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 } }) });
|