@jini-ai/daemon 0.3.8 → 0.5.1
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 +124 -0
- package/README.md +50 -8
- package/dist/agent-executor/contracts.d.ts +736 -0
- package/dist/agent-executor/contracts.js +28 -0
- package/dist/agent-executor/event-translation.d.ts +87 -0
- package/dist/agent-executor/event-translation.js +230 -0
- package/dist/agent-executor/events.d.ts +190 -0
- package/dist/agent-executor/events.js +848 -0
- package/dist/agent-executor/handoff.d.ts +18 -0
- package/dist/agent-executor/handoff.js +16 -0
- package/dist/agent-executor/index.d.ts +98 -0
- package/dist/agent-executor/index.js +98 -0
- package/dist/agent-executor/launch.d.ts +375 -0
- package/dist/agent-executor/launch.js +1001 -0
- package/dist/agent-executor/mcp-bridge.d.ts +338 -0
- package/dist/agent-executor/mcp-bridge.js +835 -0
- package/dist/agent-executor/values.d.ts +5 -0
- package/dist/agent-executor/values.js +13 -0
- package/dist/agent-executor.d.ts +375 -176
- package/dist/agent-executor.js +388 -335
- package/dist/close-status.d.ts +11 -8
- package/dist/close-status.js +31 -19
- package/dist/continuation/continuation-transport.d.ts +3 -1
- package/dist/continuation/continuation-transport.js +1 -1
- package/dist/continuation/journal.d.ts +12 -5
- package/dist/continuation/journal.js +4 -4
- package/dist/continuation/run-scoped-context-store.d.ts +13 -4
- package/dist/continuation/run-scoped-context-store.js +6 -6
- package/dist/continuation/run-start-handler.d.ts +1 -1
- package/dist/continuation/run-start-handler.js +3 -16
- package/dist/delegated-tool-bridge.d.ts +7 -3
- package/dist/delegated-tool-bridge.js +32 -31
- package/dist/event-log.d.ts +7 -5
- package/dist/event-log.js +12 -11
- package/dist/frontend-capability-tools.d.ts +1 -1
- package/dist/frontend-capability-tools.js +3 -2
- package/dist/frontend-session-registry.d.ts +32 -9
- package/dist/frontend-session-registry.js +12 -11
- package/dist/http/active-context.d.ts +105 -0
- package/dist/http/active-context.js +67 -0
- package/dist/http/agents.d.ts +68 -0
- package/dist/http/agents.js +12 -0
- package/dist/http/attachments.d.ts +618 -0
- package/dist/http/attachments.js +1034 -0
- package/dist/http/cancel-owned-runs.d.ts +49 -0
- package/dist/http/cancel-owned-runs.js +35 -0
- package/dist/http/component-catalog.d.ts +84 -0
- package/dist/http/component-catalog.js +64 -0
- package/dist/http/connectors.d.ts +209 -0
- package/dist/http/connectors.js +409 -0
- package/dist/http/daemon-status.d.ts +76 -0
- package/dist/http/daemon-status.js +55 -0
- package/dist/http/db-ops.d.ts +123 -0
- package/dist/http/db-ops.js +189 -0
- package/dist/http/delegated-tools.d.ts +113 -0
- package/dist/http/delegated-tools.js +230 -0
- package/dist/http/frontend-control.d.ts +136 -0
- package/dist/http/frontend-control.js +79 -0
- package/dist/http/frontend-sessions.d.ts +110 -0
- package/dist/http/frontend-sessions.js +180 -0
- package/dist/http/health.d.ts +95 -0
- package/dist/http/health.js +40 -0
- package/dist/http/host-tools.d.ts +214 -0
- package/dist/http/host-tools.js +329 -0
- package/dist/http/index.d.ts +52 -0
- package/dist/http/index.js +27 -0
- package/dist/http/media.d.ts +146 -0
- package/dist/http/media.js +346 -0
- package/dist/http/memory.d.ts +265 -0
- package/dist/http/memory.js +328 -0
- package/dist/http/model-proxy.d.ts +49 -0
- package/dist/http/model-proxy.js +642 -0
- package/dist/http/remote-run-events.d.ts +70 -0
- package/dist/http/remote-run-events.js +224 -0
- package/dist/http/research.d.ts +48 -0
- package/dist/http/research.js +315 -0
- package/dist/http/route-manifest.d.ts +35 -0
- package/dist/http/route-manifest.js +103 -0
- package/dist/http/routines.d.ts +120 -0
- package/dist/http/routines.js +298 -0
- package/dist/http/run-credentials.d.ts +73 -0
- package/dist/http/run-credentials.js +71 -0
- package/dist/http/runs.d.ts +91 -0
- package/dist/http/runs.js +221 -0
- package/dist/http/terminals.d.ts +138 -0
- package/dist/http/terminals.js +325 -0
- package/dist/http/tool-catalog.d.ts +78 -0
- package/dist/http/tool-catalog.js +66 -0
- package/dist/http/workspace-root.d.ts +77 -0
- package/dist/http/workspace-root.js +63 -0
- package/dist/http/xai.d.ts +90 -0
- package/dist/http/xai.js +655 -0
- package/dist/image-prompt-delivery.d.ts +13 -3
- package/dist/image-prompt-delivery.js +5 -5
- package/dist/index.d.ts +556 -4
- package/dist/index.js +556 -4
- package/dist/legacy-data-migration.d.ts +59 -8
- package/dist/legacy-data-migration.js +38 -33
- package/dist/read-only-tools.d.ts +130 -0
- package/dist/read-only-tools.js +115 -0
- package/dist/remote-tool-bridge.d.ts +11 -5
- package/dist/remote-tool-bridge.js +15 -15
- package/dist/routines/index.d.ts +1 -1
- package/dist/routines/index.js +1 -1
- package/dist/routines/routine-store.d.ts +34 -12
- package/dist/routines/routine-store.js +21 -20
- package/dist/routines/schedule.d.ts +48 -10
- package/dist/routines/schedule.js +21 -21
- package/dist/routines/scheduler.d.ts +32 -11
- package/dist/routines/scheduler.js +61 -57
- package/dist/routines/types.d.ts +10 -3
- package/dist/run/core/result.d.ts +6 -2
- package/dist/run/core/result.js +3 -3
- package/dist/run/core/retry.d.ts +17 -4
- package/dist/run/core/retry.js +7 -11
- package/dist/run/diagnostics/diagnostics.d.ts +14 -4
- package/dist/run/diagnostics/diagnostics.js +7 -7
- package/dist/run/index.d.ts +1 -1
- package/dist/run/index.js +1 -1
- package/dist/run-credentials.d.ts +138 -0
- package/dist/run-credentials.js +152 -0
- package/dist/run-lifecycle.d.ts +92 -33
- package/dist/run-lifecycle.js +81 -74
- package/dist/scheduler.d.ts +8 -0
- package/dist/scheduler.js +2 -0
- package/dist/session-coordination.d.ts +115 -0
- package/dist/session-coordination.js +111 -0
- package/dist/store/agent-sessions/errors.d.ts +8 -0
- package/dist/store/agent-sessions/errors.js +10 -0
- package/dist/store/agent-sessions/index.d.ts +12 -0
- package/dist/store/agent-sessions/index.js +19 -0
- package/dist/store/agent-sessions/pglite.d.ts +12 -0
- package/dist/store/agent-sessions/pglite.js +7 -0
- package/dist/store/agent-sessions/ports.d.ts +24 -0
- package/dist/store/agent-sessions/ports.js +2 -0
- package/dist/store/agent-sessions/postgres.d.ts +12 -0
- package/dist/store/agent-sessions/postgres.js +7 -0
- package/dist/store/agent-sessions/schema.d.ts +3 -0
- package/dist/store/agent-sessions/schema.js +23 -0
- package/dist/store/agent-sessions/sql.d.ts +35 -0
- package/dist/store/agent-sessions/sql.js +59 -0
- package/dist/store/agent-sessions/sqlite.d.ts +117 -0
- package/dist/store/agent-sessions/sqlite.js +128 -0
- package/dist/store/event-log/sqlite.d.ts +75 -0
- package/dist/store/event-log/sqlite.js +230 -0
- package/dist/surface-exchanges.d.ts +97 -0
- package/dist/surface-exchanges.js +153 -0
- package/dist/terminal-session.d.ts +43 -11
- package/dist/terminal-session.js +22 -20
- package/dist/tokens.js +4 -4
- package/dist/tool-audit.d.ts +114 -0
- package/dist/tool-audit.js +95 -0
- package/dist/tool-executor.d.ts +22 -7
- package/dist/tool-executor.js +16 -16
- package/dist/tool-result-media.d.ts +3 -1
- package/dist/tool-result-media.js +1 -1
- package/dist/tool-result-surfaces.d.ts +3 -1
- package/dist/tool-result-surfaces.js +1 -1
- package/package.json +127 -9
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Express, NextFunction, Request, Response } from 'express';
|
|
2
|
+
import type { RemoteToolEventRecorder, RunLifecycle } from '../index.js';
|
|
3
|
+
import { type AdapterContext } from '@jini-ai/http-kit';
|
|
4
|
+
export interface RemoteToolBridgeTokenConfig {
|
|
5
|
+
/** Env var name for the required shared secret. Defaults to `JINI_REMOTE_TOOL_BRIDGE_TOKEN`. */
|
|
6
|
+
readonly tokenEnvVar?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface RemoteRunEventInternalErrorContext {
|
|
9
|
+
readonly source: 'tool-use' | 'tool-result';
|
|
10
|
+
readonly correlationId: string;
|
|
11
|
+
readonly error: unknown;
|
|
12
|
+
}
|
|
13
|
+
export interface RemoteRunEventHttpDeps {
|
|
14
|
+
/** Used to distinguish "unknown run" (404) from other outcomes before recording — same precedent as `delegated-tools.ts`'s `delegatedToolExecuteRoute` — and again afterwards to classify a recorder failure (see {@link toRemoteEventError}). */
|
|
15
|
+
readonly lifecycle: RunLifecycle;
|
|
16
|
+
readonly recorder: RemoteToolEventRecorder;
|
|
17
|
+
readonly tokenConfig?: RemoteToolBridgeTokenConfig;
|
|
18
|
+
/** Defaults to `process.env`. Threaded through so tests never have to mutate real process env. */
|
|
19
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
20
|
+
/** Host-owned sink for the real exception behind a generic `INTERNAL_ERROR` response (SEC-005). Defaults to `console.error`. */
|
|
21
|
+
readonly onInternalError?: (context: RemoteRunEventInternalErrorContext) => void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Gates both remote-run-event routes behind a dedicated shared secret. Unlike
|
|
25
|
+
* `registerApiBearerAuthMiddleware`, there is no "unset means open" or "disabled" mode: a route
|
|
26
|
+
* that lets a caller inject events into someone else's run must never silently run unauthenticated
|
|
27
|
+
* just because a host forgot to configure the token. If the token isn't configured, the route
|
|
28
|
+
* fails closed with 503, not 200.
|
|
29
|
+
*
|
|
30
|
+
* Kept as its own middleware rather than folded into `api-security-middleware.ts`'s
|
|
31
|
+
* `requireStrictBearerToken` (which has the identical fail-closed/no-loopback-exemption posture)
|
|
32
|
+
* because these two routes own their own error codes — `REMOTE_TOOL_BRIDGE_*`, distinct from the
|
|
33
|
+
* general API token's, so an operator can tell which secret is missing from the response alone.
|
|
34
|
+
* The *decision* logic is shared: header parsing and the constant-time comparison both come from
|
|
35
|
+
* that module, so the two gates cannot drift on what counts as a well-formed header or on
|
|
36
|
+
* comparison safety.
|
|
37
|
+
*/
|
|
38
|
+
export declare function requireRemoteToolBridgeToken(_required: Record<string, never>, deps?: {
|
|
39
|
+
readonly tokenConfig?: RemoteToolBridgeTokenConfig | undefined;
|
|
40
|
+
readonly env?: NodeJS.ProcessEnv | undefined;
|
|
41
|
+
}): (req: Request, res: Response, next: NextFunction) => void;
|
|
42
|
+
export interface RemoteToolUseRequest {
|
|
43
|
+
readonly runId: string;
|
|
44
|
+
readonly toolUseId: string;
|
|
45
|
+
readonly toolId: string;
|
|
46
|
+
readonly input?: unknown;
|
|
47
|
+
}
|
|
48
|
+
export interface RemoteToolResultRequest {
|
|
49
|
+
readonly runId: string;
|
|
50
|
+
readonly toolUseId: string;
|
|
51
|
+
readonly content: string;
|
|
52
|
+
readonly isError?: boolean;
|
|
53
|
+
}
|
|
54
|
+
export interface RemoteRunEventResponse {
|
|
55
|
+
readonly recorded: true;
|
|
56
|
+
}
|
|
57
|
+
export declare const remoteToolUseRoute: import("@jini-ai/http-kit").JsonRouteSpec<RemoteToolUseRequest, RemoteRunEventResponse, RemoteRunEventHttpDeps>;
|
|
58
|
+
export declare const remoteToolResultRoute: import("@jini-ai/http-kit").JsonRouteSpec<RemoteToolResultRequest, RemoteRunEventResponse, RemoteRunEventHttpDeps>;
|
|
59
|
+
/**
|
|
60
|
+
* Mounts both remote-run-event routes on `app`, gated by {@link requireRemoteToolBridgeToken}.
|
|
61
|
+
* Unlike `registerRunRoutes`/`registerDelegatedToolRoutes`, these routes do not use
|
|
62
|
+
* `requireSameOrigin` — they are not meant to be called from a browser at all, only from a host's
|
|
63
|
+
* own trusted backend process, so the dedicated bearer token above is the sole gate.
|
|
64
|
+
*/
|
|
65
|
+
export declare function registerRemoteRunEventRoutes({ app, deps, adapter }: {
|
|
66
|
+
readonly app: Express;
|
|
67
|
+
readonly deps: RemoteRunEventHttpDeps;
|
|
68
|
+
readonly adapter: AdapterContext;
|
|
69
|
+
}, _optional?: Record<string, never>): void;
|
|
70
|
+
//# sourceMappingURL=remote-run-events.d.ts.map
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module remote-run-events
|
|
3
|
+
*
|
|
4
|
+
* `POST /api/runs/:runId/tool-use` and `POST /api/runs/:runId/tool-result` — lets a tool call that
|
|
5
|
+
* executed in a DIFFERENT OS process from the one holding this run's `RunLifecycle` report its
|
|
6
|
+
* outcome back into the run's own event log, so SSE subscribers (a chat UI, a reattaching client)
|
|
7
|
+
* see it exactly as they would a locally-executed delegated tool call.
|
|
8
|
+
*
|
|
9
|
+
* This exists to relax `@jini-ai/daemon`'s co-location requirement (`RunLifecycle`/`ToolExecutor`
|
|
10
|
+
* previously had to live in one process per run, because `DelegatedToolBridge.execute()` calls
|
|
11
|
+
* `lifecycle.emit()` in-process with no remote reporting path).
|
|
12
|
+
* It does not remove any authorization: whatever executed the tool remotely already ran it through
|
|
13
|
+
* its OWN `ToolExecutor`/`ToolPolicy` gate before calling here. This route only lets that outcome
|
|
14
|
+
* be recorded into a run it does not own the process of.
|
|
15
|
+
*
|
|
16
|
+
* **Deliberately a separate trust boundary from `registerApiBearerAuthMiddleware`'s general API
|
|
17
|
+
* token** (`api-security-middleware.ts`): that token may reach browser-adjacent callers depending
|
|
18
|
+
* on deployment. This route is meant only for a host's own trusted backend-to-backend delegation
|
|
19
|
+
* (e.g. a sidecar tool-execution process reporting back to the process that owns the run), so its
|
|
20
|
+
* token has no "disabled" escape hatch and does not exempt loopback peers the way the general
|
|
21
|
+
* bearer gate does — a loopback bind is exactly the shape a same-machine but lower-trust process
|
|
22
|
+
* would also present, and this route grants direct run-event-injection, a materially different
|
|
23
|
+
* capability from what the general API token was designed to gate.
|
|
24
|
+
*/
|
|
25
|
+
import { randomUUID } from 'node:crypto';
|
|
26
|
+
import { createApiError, isTerminalRunState } from '@jini-ai/protocol';
|
|
27
|
+
import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
|
|
28
|
+
import { bearerTokenFromHeader, timingSafeTokenMatch } from '@jini-ai/http-kit';
|
|
29
|
+
import { validationError } from '@jini-ai/http-kit';
|
|
30
|
+
import { sendApiError, statusForError } from '@jini-ai/http-kit';
|
|
31
|
+
import { err, ok } from '@jini-ai/http-kit';
|
|
32
|
+
function defaultInternalErrorSink(context) {
|
|
33
|
+
// eslint-disable-next-line no-console
|
|
34
|
+
console.error(`[@jini-ai/http-kit] internal error (remote-run-events/${context.source}, correlationId=${context.correlationId})`, context.error);
|
|
35
|
+
}
|
|
36
|
+
const DEFAULT_TOKEN_ENV_VAR = 'JINI_REMOTE_TOOL_BRIDGE_TOKEN';
|
|
37
|
+
/**
|
|
38
|
+
* Gates both remote-run-event routes behind a dedicated shared secret. Unlike
|
|
39
|
+
* `registerApiBearerAuthMiddleware`, there is no "unset means open" or "disabled" mode: a route
|
|
40
|
+
* that lets a caller inject events into someone else's run must never silently run unauthenticated
|
|
41
|
+
* just because a host forgot to configure the token. If the token isn't configured, the route
|
|
42
|
+
* fails closed with 503, not 200.
|
|
43
|
+
*
|
|
44
|
+
* Kept as its own middleware rather than folded into `api-security-middleware.ts`'s
|
|
45
|
+
* `requireStrictBearerToken` (which has the identical fail-closed/no-loopback-exemption posture)
|
|
46
|
+
* because these two routes own their own error codes — `REMOTE_TOOL_BRIDGE_*`, distinct from the
|
|
47
|
+
* general API token's, so an operator can tell which secret is missing from the response alone.
|
|
48
|
+
* The *decision* logic is shared: header parsing and the constant-time comparison both come from
|
|
49
|
+
* that module, so the two gates cannot drift on what counts as a well-formed header or on
|
|
50
|
+
* comparison safety.
|
|
51
|
+
*/
|
|
52
|
+
export function requireRemoteToolBridgeToken(_required, deps = {}) {
|
|
53
|
+
const tokenEnvVar = deps.tokenConfig?.tokenEnvVar ?? DEFAULT_TOKEN_ENV_VAR;
|
|
54
|
+
const env = deps.env ?? process.env;
|
|
55
|
+
return (req, res, next) => {
|
|
56
|
+
const token = env[tokenEnvVar];
|
|
57
|
+
if (!token) {
|
|
58
|
+
const error = createApiError({ code: 'REMOTE_TOOL_BRIDGE_NOT_CONFIGURED', message: `${tokenEnvVar} is not set — remote run-event ingestion is disabled` });
|
|
59
|
+
sendApiError({ res, status: statusForError({ error }), error });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const presented = bearerTokenFromHeader({ header: req.get('authorization') });
|
|
63
|
+
if (presented === null || !timingSafeTokenMatch({ presented, expected: token })) {
|
|
64
|
+
const error = createApiError({ code: 'REMOTE_TOOL_BRIDGE_TOKEN_REQUIRED', message: `Authorization: Bearer <${tokenEnvVar}> required` });
|
|
65
|
+
sendApiError({ res, status: statusForError({ error }), error });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
next();
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function isRecord(value) {
|
|
72
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
73
|
+
}
|
|
74
|
+
function requireNonEmptyString(body, key) {
|
|
75
|
+
const value = body[key];
|
|
76
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Like {@link requireNonEmptyString} but accepts `''`, for the one field where empty is a real
|
|
80
|
+
* value rather than a missing one: a tool's result `content`.
|
|
81
|
+
*
|
|
82
|
+
* The local path already produces empty content legitimately — `serializeDelegatedToolOutput`
|
|
83
|
+
* (`@jini-ai/daemon`'s `delegated-tool-bridge.ts`) maps a handler returning `undefined` to `''`,
|
|
84
|
+
* and `DelegatedToolBridge.execute()` emits that verbatim as the `tool_result` event's `content`.
|
|
85
|
+
* Rejecting `''` here would mean a tool that legitimately produces no output can be reported
|
|
86
|
+
* in-process but **not** remotely — a silent semantic divergence between the two paths that would
|
|
87
|
+
* surface as a 400 on the one tool call a host least expects to fail. Identifiers (`runId`,
|
|
88
|
+
* `toolUseId`, `toolId`) keep the non-empty check: for those, empty really is missing.
|
|
89
|
+
*/
|
|
90
|
+
function requireString(body, key) {
|
|
91
|
+
const value = body[key];
|
|
92
|
+
return typeof value === 'string' ? value : undefined;
|
|
93
|
+
}
|
|
94
|
+
function parseRunId(input) {
|
|
95
|
+
const runId = input.params.runId;
|
|
96
|
+
return typeof runId === 'string' && runId.length > 0
|
|
97
|
+
? ok({ value: runId })
|
|
98
|
+
: err({ error: validationError({ message: 'runId must be a non-empty path parameter' }) });
|
|
99
|
+
}
|
|
100
|
+
function parseRemoteToolUse(input) {
|
|
101
|
+
const parsedRunId = parseRunId(input);
|
|
102
|
+
if (!parsedRunId.ok)
|
|
103
|
+
return parsedRunId;
|
|
104
|
+
if (!isRecord(input.body))
|
|
105
|
+
return err({ error: validationError({ message: 'body must be a JSON object' }) });
|
|
106
|
+
const toolUseId = requireNonEmptyString(input.body, 'toolUseId');
|
|
107
|
+
if (toolUseId === undefined) {
|
|
108
|
+
return err({ error: validationError({ message: 'toolUseId must be a non-empty string' }, { issues: [{ path: 'toolUseId', message: 'required non-empty string' }] }) });
|
|
109
|
+
}
|
|
110
|
+
const toolId = requireNonEmptyString(input.body, 'toolId');
|
|
111
|
+
if (toolId === undefined) {
|
|
112
|
+
return err({ error: validationError({ message: 'toolId must be a non-empty string' }, { issues: [{ path: 'toolId', message: 'required non-empty string' }] }) });
|
|
113
|
+
}
|
|
114
|
+
return ok({ value: { runId: parsedRunId.value, toolUseId, toolId, input: input.body.input } });
|
|
115
|
+
}
|
|
116
|
+
function parseRemoteToolResult(input) {
|
|
117
|
+
const parsedRunId = parseRunId(input);
|
|
118
|
+
if (!parsedRunId.ok)
|
|
119
|
+
return parsedRunId;
|
|
120
|
+
if (!isRecord(input.body))
|
|
121
|
+
return err({ error: validationError({ message: 'body must be a JSON object' }) });
|
|
122
|
+
const toolUseId = requireNonEmptyString(input.body, 'toolUseId');
|
|
123
|
+
if (toolUseId === undefined) {
|
|
124
|
+
return err({ error: validationError({ message: 'toolUseId must be a non-empty string' }, { issues: [{ path: 'toolUseId', message: 'required non-empty string' }] }) });
|
|
125
|
+
}
|
|
126
|
+
// Empty is allowed here, deliberately — see `requireString`'s own doc for the local-path parity
|
|
127
|
+
// this preserves. Only a non-string (or absent) `content` is a validation failure.
|
|
128
|
+
const content = requireString(input.body, 'content');
|
|
129
|
+
if (content === undefined) {
|
|
130
|
+
return err({ error: validationError({ message: 'content must be a string' }, { issues: [{ path: 'content', message: 'required string' }] }) });
|
|
131
|
+
}
|
|
132
|
+
const isError = input.body.isError;
|
|
133
|
+
if (isError !== undefined && typeof isError !== 'boolean') {
|
|
134
|
+
return err({ error: validationError({ message: 'isError must be a boolean when provided' }, { issues: [{ path: 'isError', message: 'boolean when provided' }] }) });
|
|
135
|
+
}
|
|
136
|
+
return ok({ value: { runId: parsedRunId.value, toolUseId, content, ...(isError === undefined ? {} : { isError }) } });
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Records a caught `recordToolUse`/`recordToolResult` throw as the right HTTP outcome.
|
|
140
|
+
*
|
|
141
|
+
* `RunLifecycle.emit()` throws for two documented reasons (see `run-lifecycle.ts`'s own `emit`) —
|
|
142
|
+
* unknown `runId` and "already terminal" — but the recorder call it sits behind can also fail for
|
|
143
|
+
* reasons that are nobody's business but the operator's: an event-log write hitting a full disk, a
|
|
144
|
+
* SQLite error naming its own file path, a driver fault carrying a credential in its message.
|
|
145
|
+
*
|
|
146
|
+
* This previously reported *every* one of those as `CONFLICT` carrying `error.message` verbatim,
|
|
147
|
+
* which turned any such fault into an unauthenticated-adjacent information leak and mislabelled a
|
|
148
|
+
* server fault as a client-resolvable conflict. So the outcome is now decided by the run's actual
|
|
149
|
+
* state rather than by whatever the exception happened to say:
|
|
150
|
+
*
|
|
151
|
+
* - run is gone -> `NOT_FOUND` (it was there for the pre-check; it isn't now).
|
|
152
|
+
* - run is terminal -> `CONFLICT`, the one legitimate business conflict. The message is
|
|
153
|
+
* *constructed here* from data this module already owns rather than forwarded from the
|
|
154
|
+
* exception, so even a misattributed error cannot smuggle its text out through this branch.
|
|
155
|
+
* - anything else -> SEC-005: a generic `INTERNAL_ERROR` plus a correlation id, with the real
|
|
156
|
+
* error handed to the host's sink so it is diagnosable server-side but never echoed.
|
|
157
|
+
*
|
|
158
|
+
* Residual, deliberately accepted: a genuine storage fault that happens to coincide with an
|
|
159
|
+
* already-terminal run is reported as `CONFLICT` rather than `INTERNAL_ERROR`. Distinguishing those
|
|
160
|
+
* would require matching on `emit`'s message text, trading a rare wrong status code for a check
|
|
161
|
+
* that breaks silently the next time that string is reworded. Either way nothing leaks, which is
|
|
162
|
+
* the property that mattered.
|
|
163
|
+
*/
|
|
164
|
+
async function toRemoteEventError(error, runId, deps, source) {
|
|
165
|
+
const status = await deps.lifecycle.get({ runId }).catch(() => undefined);
|
|
166
|
+
if (status === undefined) {
|
|
167
|
+
return createApiError({ code: 'NOT_FOUND', message: `run "${runId}" was not found` });
|
|
168
|
+
}
|
|
169
|
+
if (isTerminalRunState({ state: status.state })) {
|
|
170
|
+
return createApiError({ code: 'CONFLICT', message: `run "${runId}" is already terminal — no further events can be recorded` }, {
|
|
171
|
+
details: { runId },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const correlationId = randomUUID();
|
|
175
|
+
const sink = deps.onInternalError ?? defaultInternalErrorSink;
|
|
176
|
+
sink({ source, correlationId, error });
|
|
177
|
+
return createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId });
|
|
178
|
+
}
|
|
179
|
+
export const remoteToolUseRoute = defineJsonRoute({
|
|
180
|
+
method: 'post', path: '/api/runs/:runId/tool-use', parse: parseRemoteToolUse, handle: async ({ input: input, deps: deps }) => {
|
|
181
|
+
const run = await deps.lifecycle.get({ runId: input.runId });
|
|
182
|
+
if (run === undefined)
|
|
183
|
+
return err({ error: createApiError({ code: 'NOT_FOUND', message: `run "${input.runId}" was not found` }) });
|
|
184
|
+
try {
|
|
185
|
+
await deps.recorder.recordToolUse({ runId: input.runId, record: { toolUseId: input.toolUseId, toolId: input.toolId, input: input.input } });
|
|
186
|
+
return ok({ value: { recorded: true } });
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
return err({ error: await toRemoteEventError(error, input.runId, deps, 'tool-use') });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
export const remoteToolResultRoute = defineJsonRoute({
|
|
194
|
+
method: 'post', path: '/api/runs/:runId/tool-result', parse: parseRemoteToolResult, handle: async ({ input: input, deps: deps }) => {
|
|
195
|
+
const run = await deps.lifecycle.get({ runId: input.runId });
|
|
196
|
+
if (run === undefined)
|
|
197
|
+
return err({ error: createApiError({ code: 'NOT_FOUND', message: `run "${input.runId}" was not found` }) });
|
|
198
|
+
try {
|
|
199
|
+
await deps.recorder.recordToolResult({ runId: input.runId, record: {
|
|
200
|
+
toolUseId: input.toolUseId,
|
|
201
|
+
content: input.content,
|
|
202
|
+
...(input.isError === undefined ? {} : { isError: input.isError }),
|
|
203
|
+
} });
|
|
204
|
+
return ok({ value: { recorded: true } });
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
return err({ error: await toRemoteEventError(error, input.runId, deps, 'tool-result') });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
/**
|
|
212
|
+
* Mounts both remote-run-event routes on `app`, gated by {@link requireRemoteToolBridgeToken}.
|
|
213
|
+
* Unlike `registerRunRoutes`/`registerDelegatedToolRoutes`, these routes do not use
|
|
214
|
+
* `requireSameOrigin` — they are not meant to be called from a browser at all, only from a host's
|
|
215
|
+
* own trusted backend process, so the dedicated bearer token above is the sole gate.
|
|
216
|
+
*/
|
|
217
|
+
export function registerRemoteRunEventRoutes({ app, deps, adapter }, _optional = {}) {
|
|
218
|
+
const gate = requireRemoteToolBridgeToken({}, deps);
|
|
219
|
+
app.use('/api/runs/:runId/tool-use', gate);
|
|
220
|
+
app.use('/api/runs/:runId/tool-result', gate);
|
|
221
|
+
mountJsonRoute({ app, spec: remoteToolUseRoute, deps, adapter });
|
|
222
|
+
mountJsonRoute({ app, spec: remoteToolResultRoute, deps, adapter });
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=remote-run-events.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Express } from 'express';
|
|
2
|
+
import { type AdapterContext } from '@jini-ai/http-kit';
|
|
3
|
+
export interface ResearchSource {
|
|
4
|
+
readonly title: string;
|
|
5
|
+
readonly url: string;
|
|
6
|
+
readonly snippet: string;
|
|
7
|
+
readonly publishedAt?: string;
|
|
8
|
+
readonly provider: string;
|
|
9
|
+
}
|
|
10
|
+
/** Matches OD's real `@open-design/contracts/api/research.ts` `ResearchFindings` shape — see module doc. */
|
|
11
|
+
export interface ResearchSearchResponse {
|
|
12
|
+
readonly query: string;
|
|
13
|
+
readonly summary: string;
|
|
14
|
+
readonly sources: readonly ResearchSource[];
|
|
15
|
+
readonly provider: string;
|
|
16
|
+
readonly depth: 'shallow';
|
|
17
|
+
readonly fetchedAt: number;
|
|
18
|
+
}
|
|
19
|
+
export interface ResearchInternalErrorContext {
|
|
20
|
+
readonly correlationId: string;
|
|
21
|
+
readonly error: unknown;
|
|
22
|
+
}
|
|
23
|
+
export interface ResearchProviderCredentials {
|
|
24
|
+
readonly apiKey?: string;
|
|
25
|
+
readonly baseUrl?: string;
|
|
26
|
+
readonly model?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface ResearchHttpDeps {
|
|
29
|
+
/** Resolves per-provider credentials. Defaults to `TAVILY_API_KEY` for the built-in Tavily transport. */
|
|
30
|
+
readonly resolveCredentials?: ({ providerId }: {
|
|
31
|
+
providerId: string;
|
|
32
|
+
}) => Promise<ResearchProviderCredentials>;
|
|
33
|
+
/** Host-owned sink for the real exception behind a generic `INTERNAL_ERROR` response (SEC-005). Defaults to `console.error`. */
|
|
34
|
+
readonly onInternalError?: (context: ResearchInternalErrorContext) => void;
|
|
35
|
+
}
|
|
36
|
+
interface ResearchSearchRequest {
|
|
37
|
+
readonly query: string;
|
|
38
|
+
readonly maxSources?: number;
|
|
39
|
+
}
|
|
40
|
+
export declare const researchSearchRoute: import("@jini-ai/http-kit").JsonRouteSpec<ResearchSearchRequest, ResearchSearchResponse, ResearchHttpDeps>;
|
|
41
|
+
/** Mounts `POST /api/research/search` on `app`. A pack's `http(app, services)` calls this directly. */
|
|
42
|
+
export declare function registerResearchRoutes({ app, deps, adapter }: {
|
|
43
|
+
readonly app: Express;
|
|
44
|
+
readonly deps: ResearchHttpDeps;
|
|
45
|
+
readonly adapter: AdapterContext;
|
|
46
|
+
}, _optional?: Record<string, never>): void;
|
|
47
|
+
export {};
|
|
48
|
+
//# sourceMappingURL=research.d.ts.map
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { redactSecrets } from '@jini-ai/core';
|
|
2
|
+
/**
|
|
3
|
+
* @module research
|
|
4
|
+
*
|
|
5
|
+
* `POST /api/research/search` — a thin route wrapper over Tavily's real Search API
|
|
6
|
+
* (`POST {baseUrl}/search`), ported from OD's `apps/daemon/src/research/tavily.ts` (the low-level
|
|
7
|
+
* Tavily client) **and** `apps/daemon/src/research/index.ts#searchResearch` (the findings-assembly
|
|
8
|
+
* wrapper the real `/api/research/search` route in `apps/daemon/src/routes/media.ts` actually
|
|
9
|
+
* calls). Verified against a **live** boot of OD's real daemon (`apps/daemon/bin/od.mjs`, from the
|
|
10
|
+
* sibling `/Users/la/Desktop/Programming/OSS-Repos/open-design` checkout on this machine) hitting
|
|
11
|
+
* the real route with a mock Tavily backend, not just static reading — see the field-by-field notes
|
|
12
|
+
* below for what that live diff actually found. The request/response shape and defaults are ported
|
|
13
|
+
* (`search_depth: 'basic'`, `include_answer: true`, `include_raw_content: false`, a 20-result hard
|
|
14
|
+
* cap, the `results[]` -> `ResearchSource[]` mapping, the 1000-char query cap, the
|
|
15
|
+
* `Math.floor`+clamp-to-`[1,20]` `maxSources` normalization, and the `NO_RESEARCH_SOURCES`-style
|
|
16
|
+
* "zero sources is a failure, not an empty success" behavior) — OD's own `TavilyError`/
|
|
17
|
+
* `ResearchError` classes and `requestInit.dispatcher` passthrough are not carried over; this route
|
|
18
|
+
* pack's own SEC-005 error-handling convention (a generic `INTERNAL_ERROR` + correlation id,
|
|
19
|
+
* matching every other route pack in this package) replaces the origin's thrown-error shape for
|
|
20
|
+
* genuinely unexpected failures. OD's `AbortController`-based 30s timeout **is** carried over
|
|
21
|
+
* (added after an external audit flagged its absence — a stalled/malicious upstream could otherwise
|
|
22
|
+
* hold a request indefinitely, since bare `fetch` has no default timeout): `tavilySearch` aborts
|
|
23
|
+
* the request after `DEFAULT_TAVILY_TIMEOUT_MS` and clears the timer in a `finally` regardless of
|
|
24
|
+
* outcome. No other request-level knobs (connection pooling, `dispatcher`) are exposed yet — a
|
|
25
|
+
* future pass can add them the same way `media.ts#MediaGenerationRequestInit` optionally threads a
|
|
26
|
+
* `dispatcher` through, if a real need for one shows up.
|
|
27
|
+
*
|
|
28
|
+
* **`ResearchSource` field names match OD's real `@open-design/contracts/api/research.ts`
|
|
29
|
+
* `ResearchSource` shape** (`title`/`url`/`snippet`/`publishedAt?`/`provider`). **`ResearchSearchResponse`
|
|
30
|
+
* matches that same contract file's `ResearchFindings` shape** (`query`/`summary`/`sources`/
|
|
31
|
+
* `provider`/`depth`/`fetchedAt`) — an earlier version of this port conflated the *raw Tavily
|
|
32
|
+
* client's* return shape (`tavily.ts`'s `{ answer, sources }`) with the *route's actual HTTP
|
|
33
|
+
* response* shape (`research/index.ts#searchResearch`'s `ResearchFindings`), which the live daemon
|
|
34
|
+
* diff caught: hitting the real `/api/research/search` with a mock Tavily backend returned
|
|
35
|
+
* `{"query":"...","summary":"...","sources":[...],"provider":"tavily","depth":"shallow",
|
|
36
|
+
* "fetchedAt":<ms>}`, never `{answer, sources}`. `depth` is hardcoded to the `'shallow'` literal
|
|
37
|
+
* (rather than OD's full `'shallow' | 'medium' | 'deep'` `ResearchDepth` union) since this port, like
|
|
38
|
+
* OD's real Phase-1 `searchResearch`, only ever produces `'shallow'` — there is no depth-selection
|
|
39
|
+
* request field on either side yet.
|
|
40
|
+
*
|
|
41
|
+
* **`providers` request field**: OD's real route accepts an optional `providers: string[]` and only
|
|
42
|
+
* inspects `providers[0]` (defaulting to `'tavily'` when absent/empty); a non-array value is treated
|
|
43
|
+
* as absent (not rejected), and a first element other than `'tavily'` is rejected with a 400 before
|
|
44
|
+
* credentials are even resolved (`UNSUPPORTED_RESEARCH_PROVIDER` in OD; this port's own generic
|
|
45
|
+
* `BAD_REQUEST` validation-error convention here, same as every other `parse`-time rejection in this
|
|
46
|
+
* file). Confirmed live: `providers: ["bing"]` 400s on the real daemon, `providers: ["tavily"]`
|
|
47
|
+
* succeeds identically to omitting the field.
|
|
48
|
+
*
|
|
49
|
+
* **Credential resolution**: `resolveCredentials` defaults to this transport's narrow
|
|
50
|
+
* `TAVILY_API_KEY` environment seam. A host with its own secrets layer supplies its own resolver
|
|
51
|
+
* instead; this daemon route pack deliberately does not depend on the incubating media implementation
|
|
52
|
+
* package. Note OD's real env-var precedence is
|
|
53
|
+
* `['OD_TAVILY_API_KEY', 'TAVILY_API_KEY']` (checked live via `apps/daemon/tests/research.test.ts`'s
|
|
54
|
+
* `TAVILY_ENV_KEYS`) — this package's own `@jini-ai/media#PROVIDER_CREDENTIAL_ENV_VARS.tavily` only
|
|
55
|
+
* lists `['TAVILY_API_KEY']`, deliberately dropping the `OD_`-prefixed variant: this repo's own
|
|
56
|
+
* `AGENTS.md` boundary rule bans product-identity strings (including the `OD_` prefix) inside
|
|
57
|
+
* `packages/@jini-ai/**`, so that omission is a hard constraint, not a port gap.
|
|
58
|
+
*
|
|
59
|
+
* **Missing credentials is a clean `NOT_CONFIGURED` (503), not an `INTERNAL_ERROR`.** No API key
|
|
60
|
+
* configured is an expected, non-exceptional state (matching `connectors.ts`'s "capability exists,
|
|
61
|
+
* reachable, inert until configured" shape) — it never reaches the SEC-005 sink, since there is no
|
|
62
|
+
* real exception or secret to hide, just an honest "not set up yet" answer. OD's real behavior here
|
|
63
|
+
* (confirmed live) is a 400 `TAVILY_API_KEY_MISSING`, not a 503 — this is a deliberate, documented
|
|
64
|
+
* divergence from OD (this package's own generic-error-code convention, applied consistently across
|
|
65
|
+
* every route pack) rather than an oversight, so it is intentionally **not** changed to match OD's
|
|
66
|
+
* 400.
|
|
67
|
+
*
|
|
68
|
+
* **Zero sources is a `NOT_FOUND` (404), not a `200` with an empty array.** OD's real
|
|
69
|
+
* `searchResearch` throws `ResearchError('no sources found', 404, 'NO_RESEARCH_SOURCES')` when
|
|
70
|
+
* Tavily's `results[]` maps to zero usable sources (confirmed live against a mock Tavily backend
|
|
71
|
+
* that returns `results: []`) — this port matches that with the package's own generic `NOT_FOUND`
|
|
72
|
+
* code (mapped to 404 by `response.ts#ERROR_STATUS_BY_CODE`) rather than inventing a
|
|
73
|
+
* research-specific code, same rationale as the `NOT_CONFIGURED` choice above.
|
|
74
|
+
*
|
|
75
|
+
* **Query normalization**: OD's real `searchResearch` trims the query and caps it at 1000 characters
|
|
76
|
+
* before ever calling Tavily (`(input.query?.trim() || '').slice(0, 1000)`) — confirmed live by
|
|
77
|
+
* sending a padded, 1112-char query and observing the daemon forward exactly 1000 trimmed
|
|
78
|
+
* characters to the mock Tavily backend. This port replicates both the trim and the cap in
|
|
79
|
+
* `parseResearchSearch`.
|
|
80
|
+
*
|
|
81
|
+
* **`maxSources` normalization**: OD's real `clampMaxSources` is `Math.max(1, Math.min(Math.floor(value),
|
|
82
|
+
* 20))` — confirmed live by sending `maxSources: 2.7` and observing `max_results: 2` (not `2.7`) on
|
|
83
|
+
* the wire to the mock Tavily backend. An earlier version of this port only clamped the range
|
|
84
|
+
* (`Math.max(0, Math.min(value, 20))`) without flooring, which could forward a fractional
|
|
85
|
+
* `max_results` straight to Tavily's real API for any non-integer input (e.g. `maxSources: 0.5`
|
|
86
|
+
* would have clamped to `0.5`, not `1`); this port now floors first, matching OD.
|
|
87
|
+
*
|
|
88
|
+
* **SEC-005 for everything else**: a genuine Tavily request/response failure is caught, reported
|
|
89
|
+
* through `onInternalError` (defaults to `console.error`, matching every other route pack), and
|
|
90
|
+
* surfaced to the caller as a generic `INTERNAL_ERROR` — the raw error message (which can echo
|
|
91
|
+
* request details) is never sent to the HTTP caller directly. `redactSecrets` (reused from
|
|
92
|
+
* `@jini-ai/core`'s `connection-guard.ts`, already reachable via this package's existing
|
|
93
|
+
* `@jini-ai/agent-runtime` dependency — the same "shared primitive, one implementation" reuse this
|
|
94
|
+
* task's agent-runtime work applied to `sse-decode.ts`/`turn-end-guard.ts`) strips the bearer
|
|
95
|
+
* token out of any upstream error text before it is even logged, belt-and-braces alongside the
|
|
96
|
+
* generic-message substitution.
|
|
97
|
+
*/
|
|
98
|
+
import { randomUUID } from 'node:crypto';
|
|
99
|
+
import { validateBaseUrl } from '@jini-ai/agent-runtime';
|
|
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';
|
|
104
|
+
const DEFAULT_TAVILY_BASE_URL = 'https://api.tavily.com';
|
|
105
|
+
/** Tavily's own documented cap on `max_results` (see OD's `tavily.ts#TAVILY_MAX_RESULTS_LIMIT`). */
|
|
106
|
+
const TAVILY_MAX_RESULTS_LIMIT = 20;
|
|
107
|
+
const DEFAULT_MAX_SOURCES = 5;
|
|
108
|
+
/** Matches OD's own `tavily.ts` timeout — see module doc. */
|
|
109
|
+
const DEFAULT_TAVILY_TIMEOUT_MS = 30_000;
|
|
110
|
+
/** Matches OD's real `research/index.ts#searchResearch` query cap — see module doc. */
|
|
111
|
+
const MAX_QUERY_LENGTH = 1000;
|
|
112
|
+
function defaultInternalErrorSink(context) {
|
|
113
|
+
// eslint-disable-next-line no-console
|
|
114
|
+
console.error(`[@jini-ai/http-kit] internal error (research/search, correlationId=${context.correlationId})`, context.error);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Default `ResearchHttpDeps.resolveCredentials`: reads `TAVILY_API_KEY` from the environment.
|
|
118
|
+
*
|
|
119
|
+
* Takes no `providerId` even though the `resolveCredentials` dep type supplies one (a function of
|
|
120
|
+
* fewer parameters is assignable, so this still satisfies the interface). It used to accept one and
|
|
121
|
+
* return `{}` for anything other than `'tavily'`, but that arm was unreachable twice over: the only
|
|
122
|
+
* call site below passes the literal `'tavily'`, and `validateRequestedProvider` already rejects any
|
|
123
|
+
* other provider name at parse time. A host that supports a second provider injects its own
|
|
124
|
+
* `resolveCredentials` rather than reaching this function with a different id.
|
|
125
|
+
*/
|
|
126
|
+
async function defaultResolveCredentials() {
|
|
127
|
+
const apiKey = process.env.TAVILY_API_KEY?.trim();
|
|
128
|
+
return apiKey ? { apiKey } : {};
|
|
129
|
+
}
|
|
130
|
+
function isRecord(value) {
|
|
131
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
132
|
+
}
|
|
133
|
+
function nonEmptyString(value) {
|
|
134
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Only `'tavily'` is a supported provider (matches OD's real Phase-1 `searchResearch` — see module
|
|
138
|
+
* doc). OD only inspects `providers[0]` and treats a non-array `providers` value as absent rather
|
|
139
|
+
* than rejecting it; this mirrors both.
|
|
140
|
+
*/
|
|
141
|
+
function validateRequestedProvider(rawProviders) {
|
|
142
|
+
if (!Array.isArray(rawProviders))
|
|
143
|
+
return ok({ value: null });
|
|
144
|
+
const filtered = rawProviders.filter((p) => typeof p === 'string' && p.length > 0);
|
|
145
|
+
const requested = filtered[0];
|
|
146
|
+
if (requested !== undefined && requested !== 'tavily') {
|
|
147
|
+
return err({ error: validationError({ message: `provider "${requested}" is not supported — only "tavily" is available` }) });
|
|
148
|
+
}
|
|
149
|
+
return ok({ value: null });
|
|
150
|
+
}
|
|
151
|
+
function parseResearchSearch(input) {
|
|
152
|
+
if (!isRecord(input.body))
|
|
153
|
+
return err({ error: validationError({ message: 'body must be a JSON object' }) });
|
|
154
|
+
const rawQuery = nonEmptyString(input.body.query);
|
|
155
|
+
if (!rawQuery)
|
|
156
|
+
return err({ error: validationError({ message: 'query must be a non-empty string' }, { issues: [{ path: 'query', message: 'required non-empty string' }] }) });
|
|
157
|
+
const query = rawQuery.trim().slice(0, MAX_QUERY_LENGTH);
|
|
158
|
+
const maxSources = input.body.maxSources;
|
|
159
|
+
if (maxSources !== undefined && (typeof maxSources !== 'number' || !Number.isFinite(maxSources) || maxSources <= 0)) {
|
|
160
|
+
return err({ error: validationError({ message: 'maxSources must be a positive number when provided' }) });
|
|
161
|
+
}
|
|
162
|
+
const providerCheck = validateRequestedProvider(input.body.providers);
|
|
163
|
+
if (!providerCheck.ok)
|
|
164
|
+
return providerCheck;
|
|
165
|
+
return ok({ value: { query, ...(maxSources !== undefined ? { maxSources: maxSources } : {}) } });
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Calls Tavily's real `POST /search` endpoint directly — see module doc for the port's exact
|
|
169
|
+
* scope/provenance. Throws (never a `Result`) on any failure; the route handler below is the one
|
|
170
|
+
* place that converts a thrown error into the package's SEC-005 response shape.
|
|
171
|
+
*
|
|
172
|
+
* `apiKey` is a separate, non-optional parameter rather than being read off `credentials`, so the
|
|
173
|
+
* "credential is present" invariant is carried by the type system instead of by a runtime guard.
|
|
174
|
+
* This function used to take only `credentials` and re-check `credentials.apiKey` itself, but the
|
|
175
|
+
* sole caller below already returns `NOT_CONFIGURED` before it ever gets here — the check was
|
|
176
|
+
* unreachable by construction, so it is now expressed as a required argument instead of dead code.
|
|
177
|
+
* `credentials` is still passed for its optional transport config (`baseUrl`).
|
|
178
|
+
*/
|
|
179
|
+
async function tavilySearch(apiKey, credentials, request) {
|
|
180
|
+
const baseUrl = credentials.baseUrl ?? DEFAULT_TAVILY_BASE_URL;
|
|
181
|
+
const baseUrlCheck = validateBaseUrl({ baseUrl });
|
|
182
|
+
if (baseUrlCheck.error) {
|
|
183
|
+
throw new Error(baseUrlCheck.error);
|
|
184
|
+
}
|
|
185
|
+
const base = baseUrl.replace(/\/+$/, '');
|
|
186
|
+
// Floor before clamping, matching OD's real `clampMaxSources` — see module doc. Without the
|
|
187
|
+
// floor, a fractional `maxSources` (e.g. `2.7`, or `0.5` before the `Math.max(1, ...)` floor
|
|
188
|
+
// below) would forward a non-integer `max_results` straight to Tavily's real API.
|
|
189
|
+
const maxResults = Math.max(1, Math.min(Math.floor(request.maxSources ?? DEFAULT_MAX_SOURCES), TAVILY_MAX_RESULTS_LIMIT));
|
|
190
|
+
const body = {
|
|
191
|
+
query: request.query,
|
|
192
|
+
search_depth: 'basic',
|
|
193
|
+
max_results: maxResults,
|
|
194
|
+
include_answer: true,
|
|
195
|
+
include_raw_content: false,
|
|
196
|
+
};
|
|
197
|
+
// The timeout must stay armed for the full operation, not just until `fetch` resolves —
|
|
198
|
+
// `fetch` resolves as soon as response headers arrive, before the body is read. A round-2
|
|
199
|
+
// external audit caught (and locally reproduced) an earlier version of this fix that cleared
|
|
200
|
+
// the timer right after `fetch` resolved, leaving `response.text()`/`response.json()` below
|
|
201
|
+
// completely unbounded against a server that sends headers promptly and then stalls the body.
|
|
202
|
+
//
|
|
203
|
+
// `TavilyHttpError` marks an error already redacted and formatted by the `!response.ok` branch
|
|
204
|
+
// below, so the outer catch can rethrow it verbatim instead of wrapping it a second time (which
|
|
205
|
+
// would otherwise produce a "Tavily request failed: Tavily 429: ..." double-prefixed message).
|
|
206
|
+
class TavilyHttpError extends Error {
|
|
207
|
+
}
|
|
208
|
+
const timeoutController = new AbortController();
|
|
209
|
+
const timeoutHandle = setTimeout(() => timeoutController.abort(), DEFAULT_TAVILY_TIMEOUT_MS);
|
|
210
|
+
// Assigned in the `try` and returned *after* the `finally`, rather than returned from inside the
|
|
211
|
+
// `try`. Both forms behave identically, but a return-through-`finally` compiles to three copies of
|
|
212
|
+
// the `finally` body (normal completion, return, throw) and one of those is unreachable here — the
|
|
213
|
+
// `try` cannot fall off its end and the `catch` always throws. Falling through to a single return
|
|
214
|
+
// keeps the cleanup path honestly measurable instead of permanently one-third uncovered.
|
|
215
|
+
let output;
|
|
216
|
+
try {
|
|
217
|
+
const response = await fetch(`${base}/search`, {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
220
|
+
body: JSON.stringify(body),
|
|
221
|
+
signal: timeoutController.signal,
|
|
222
|
+
});
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
const text = await response.text().catch(() => '');
|
|
225
|
+
throw new TavilyHttpError(redactSecrets({ input: `Tavily ${response.status}: ${text.slice(0, 200) || 'no body'}` }, { exactSecrets: [apiKey] }));
|
|
226
|
+
}
|
|
227
|
+
const json = (await response.json());
|
|
228
|
+
const answer = typeof json.answer === 'string' ? json.answer : '';
|
|
229
|
+
const rawResults = Array.isArray(json.results) ? json.results : [];
|
|
230
|
+
const sources = [];
|
|
231
|
+
for (const raw of rawResults) {
|
|
232
|
+
const url = typeof raw.url === 'string' ? raw.url : '';
|
|
233
|
+
if (!url)
|
|
234
|
+
continue;
|
|
235
|
+
const publishedAt = typeof raw.published_date === 'string' && raw.published_date.trim() ? raw.published_date.trim() : undefined;
|
|
236
|
+
sources.push({
|
|
237
|
+
title: typeof raw.title === 'string' && raw.title.trim() ? raw.title.trim() : url,
|
|
238
|
+
url,
|
|
239
|
+
snippet: typeof raw.content === 'string' ? raw.content.trim().slice(0, 800) : '',
|
|
240
|
+
provider: 'tavily',
|
|
241
|
+
...(publishedAt ? { publishedAt } : {}),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
output = { answer, sources };
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
if (!timeoutController.signal.aborted && error instanceof TavilyHttpError) {
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
251
|
+
throw new Error(redactSecrets({
|
|
252
|
+
input: timeoutController.signal.aborted
|
|
253
|
+
? `Tavily request timed out after ${DEFAULT_TAVILY_TIMEOUT_MS}ms`
|
|
254
|
+
: `Tavily request failed: ${message}`,
|
|
255
|
+
}, { exactSecrets: [apiKey] }));
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
clearTimeout(timeoutHandle);
|
|
259
|
+
}
|
|
260
|
+
return output;
|
|
261
|
+
}
|
|
262
|
+
/** Ported verbatim from OD's real `research/index.ts#synthesizeFallbackSummary` — see module doc. Used when Tavily returns no `answer` text so the caller still gets a usable `summary`. */
|
|
263
|
+
function synthesizeFallbackSummary(sources) {
|
|
264
|
+
const lead = sources
|
|
265
|
+
.slice(0, 5)
|
|
266
|
+
.map((s, i) => `- [${i + 1}] ${s.title}: ${s.snippet.slice(0, 200)}`)
|
|
267
|
+
.join('\n');
|
|
268
|
+
return `(No provider summary; top snippets follow.)\n${lead}`;
|
|
269
|
+
}
|
|
270
|
+
export const researchSearchRoute = defineJsonRoute({
|
|
271
|
+
method: 'post', path: '/api/research/search', parse: parseResearchSearch, handle: async ({ input: request, deps }) => {
|
|
272
|
+
const resolveCredentials = deps.resolveCredentials ?? defaultResolveCredentials;
|
|
273
|
+
const onInternalError = deps.onInternalError ?? defaultInternalErrorSink;
|
|
274
|
+
let credentials;
|
|
275
|
+
try {
|
|
276
|
+
credentials = await resolveCredentials({ providerId: 'tavily' });
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
const correlationId = randomUUID();
|
|
280
|
+
onInternalError({ correlationId, error });
|
|
281
|
+
return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
|
|
282
|
+
}
|
|
283
|
+
const apiKey = credentials.apiKey;
|
|
284
|
+
if (!apiKey) {
|
|
285
|
+
return err({ error: createApiError({ code: 'NOT_CONFIGURED', message: 'tavily provider not configured' }) });
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const raw = await tavilySearch(apiKey, credentials, request);
|
|
289
|
+
// Matches OD's real `NO_RESEARCH_SOURCES` (404) — see module doc. Zero usable sources is a
|
|
290
|
+
// failure, not an empty success, even when the upstream Tavily call itself succeeded.
|
|
291
|
+
if (raw.sources.length === 0) {
|
|
292
|
+
return err({ error: createApiError({ code: 'NOT_FOUND', message: 'no sources found' }) });
|
|
293
|
+
}
|
|
294
|
+
const result = {
|
|
295
|
+
query: request.query,
|
|
296
|
+
summary: raw.answer || synthesizeFallbackSummary(raw.sources),
|
|
297
|
+
sources: raw.sources,
|
|
298
|
+
provider: 'tavily',
|
|
299
|
+
depth: 'shallow',
|
|
300
|
+
fetchedAt: Date.now(),
|
|
301
|
+
};
|
|
302
|
+
return ok({ value: result });
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
const correlationId = randomUUID();
|
|
306
|
+
onInternalError({ correlationId, error });
|
|
307
|
+
return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}, { requireSameOrigin: true });
|
|
311
|
+
/** Mounts `POST /api/research/search` on `app`. A pack's `http(app, services)` calls this directly. */
|
|
312
|
+
export function registerResearchRoutes({ app, deps, adapter }, _optional = {}) {
|
|
313
|
+
mountJsonRoute({ app, spec: researchSearchRoute, deps, adapter });
|
|
314
|
+
}
|
|
315
|
+
//# sourceMappingURL=research.js.map
|