From 3da324d1e2aee5b8b04619cec44004eaf7acb4c4 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 25 Jul 2026 15:38:09 +0800 Subject: [PATCH] refactor(session-query): split model-facing tool modules --- ...model-facing-session-query-tools.i18n.yaml | 4 +- ...-07-24-model-facing-session-query-tools.md | 2 + ...-24-model-facing-session-query-tools.zh.md | 2 + docs/config-catalog.md | 2 +- .../tool-session-query/src/index.ts | 1147 +---------------- .../tool-session-query/src/input.ts | 307 +++++ .../tool-session-query/src/operations.ts | 281 ++++ .../tool-session-query/src/presentation.ts | 255 ++++ .../src/service-boundary.ts | 171 +++ .../src/workspace-access.ts | 255 ++++ 10 files changed, 1295 insertions(+), 1131 deletions(-) create mode 100644 packages/session-query/tool-session-query/src/input.ts create mode 100644 packages/session-query/tool-session-query/src/operations.ts create mode 100644 packages/session-query/tool-session-query/src/presentation.ts create mode 100644 packages/session-query/tool-session-query/src/service-boundary.ts create mode 100644 packages/session-query/tool-session-query/src/workspace-access.ts diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index 88363d0aca..86b4e1deed 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-model-facing-session-query-tools.md: aea490f3569dd95bffb6ebbaae5a130e6440c281 -2026-07-24-model-facing-session-query-tools.zh.md: eae100375fe7abf91ba3e503808a6d98b540255e +2026-07-24-model-facing-session-query-tools.md: bc9143150d1e17eda9eab7f4864ed3a2f4983157 +2026-07-24-model-facing-session-query-tools.zh.md: c8a0c70789f21e4bbca523b6acc81925fb17b604 diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index aea490f356..bc9143150d 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -12,6 +12,8 @@ The unified `ctx.sessionQuery` service exposes exact reads, filters, relationshi `@deepseek-ai/dsh-tool-session-query` is the model-facing consumer of `ctx.sessionQuery`. It registers five narrow read-only tools: `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`. The package imports the interface rather than the SQLite implementation, owns model argument validation and readable text rendering, and contributes one concise prompt section that teaches the prior-history search and search-to-trace/read workflow. +The package entrypoint is only the public composition root for configuration, prompt registration, and tool registration. Its internal modules follow the execution boundary: `input.ts` owns model schemas, normalization, and filter construction; `service-boundary.ts` contains provider calls and model-safe error translation; `workspace-access.ts` owns caller identity, workspace authorization, title access, and lineage projection; `operations.ts` orchestrates the five service workflows; and `presentation.ts` renders tool results and call cards. This keeps policy in its owning layer without changing the package contract. + `session_search` groups full-text matches by session and exposes typed session and event metadata filters. `session_event_search` searches one session, defaulting to the caller's current session. `session_trace` returns the complete authorized ancestor chain and recursive descendant trees. `session_event_trace` returns every known positional replacement and direct provenance relationship for one event. `session_event_read` returns the exact target event as unabridged JSON and optionally summarizes a bounded raw-event window; omitted `before` and `after` values mean target-only. Model-facing filters use flat snake-case fields. Timestamps are timezone-qualified ISO 8601 strings at the tool boundary, convert to inclusive epoch-millisecond ranges for the service, and render as UTC ISO 8601. List values are ORed inside one filter while separate filters are ANDed. Requested parent ids are deduplicated and authority-filtered before FTS, so only parents in the caller workspace enter the provider clause; missing and cross-workspace guesses behave identically, while the root-session marker remains independently ORed into that clause. Event type strings remain open because `SessionEventMap` is merge-extensible; availability and event surface use closed values. diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index eae100375f..c8a0c70789 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -12,6 +12,8 @@ Status: implemented `@deepseek-ai/dsh-tool-session-query` 是 `ctx.sessionQuery` 面向模型的消费者。它注册五个职责单一的只读工具:`session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`。该包依赖接口而非 SQLite 实现,负责模型参数校验与易读文本渲染,并贡献一个精简的提示词段,说明历史搜索以及从搜索转向追踪/读取的工作流。 +该包入口仅作为配置、提示词注册与工具注册的公开组合根。内部模块沿执行边界划分:`input.ts` 负责模型 schema、规范化与过滤条件构造;`service-boundary.ts` 包含提供方调用与面向模型的安全错误转换;`workspace-access.ts` 负责调用者身份、工作区授权、标题访问与谱系投影;`operations.ts` 编排五个服务工作流;`presentation.ts` 渲染工具结果与调用卡片。这样可让策略留在其所属层,同时不改变包契约。 + `session_search` 按会话聚合全文匹配,并公开带类型的会话与事件元数据过滤条件。`session_event_search` 搜索一个会话,默认目标为调用者的当前会话。`session_trace` 返回完整的已授权祖先链与递归后代树。`session_event_trace` 返回一个事件所有已知的位置替换关系与直接来源关系。`session_event_read` 以未删节 JSON 返回准确的目标事件,并可选择汇总一个有界的原始事件窗口;省略 `before` 与 `after` 时只返回目标。 面向模型的过滤条件使用扁平的 snake-case 字段。工具边界上的时间戳采用带时区的 ISO 8601 字符串,转换为服务使用的闭区间毫秒时间戳,并以 UTC ISO 8601 渲染。同一个过滤条件中的列表值按 OR 组合,不同过滤条件按 AND 组合。请求的父会话 id 会在 FTS 之前去重并按权限过滤,因此只有调用者工作区中的父会话会进入提供方条件;缺失与跨工作区的猜测具有相同行为,而根会话标记仍会独立按 OR 加入该条件。由于 `SessionEventMap` 可通过声明合并扩展,事件类型字符串保持开放;可用状态与事件表层使用封闭取值。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a1b9d4449f..bb6690bfb1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1560,7 +1560,7 @@ export interface Config { } ``` -Source: [`packages/session-query/tool-session-query/src/index.ts:52`](../packages/session-query/tool-session-query/src/index.ts) +Source: [`packages/session-query/tool-session-query/src/index.ts:29`](../packages/session-query/tool-session-query/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index e05ab89218..d6eb659b4d 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -6,35 +6,12 @@ import type { Context } from 'cordis' import z from 'schemastery' -import { HarnessError } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import { - SessionId, - type SessionEvent, - type SessionEventType, - type SessionHeader, - type SessionId as SessionIdValue, -} from '@deepseek-ai/dsh-session' -import { - SessionQueryError, - extractSessionEventText, - type SessionAvailability, - type SessionEventMetadataFilter, - type SessionEventSearchPage, - type SessionEventSearchHit, - type SessionEventSurface, - type SessionEventTraceObservation, - type SessionEventWindow, - type SessionLineageNode, - type SessionLineageTrace, - type SessionRecord, - type SessionResultFilter, - type SessionQueryErrorCode, - type SessionSearchCursor, - type SessionSearchHit, -} from '@deepseek-ai/dsh-session-query' -import { defineTool, type GenericCallView, type ToolRunContext } from '@deepseek-ai/dsh-tools' +import { defineTool } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' +import { toolInput } from './input.ts' +import { operations } from './operations.ts' +import { presentation } from './presentation.ts' /** Cordis plugin name used by Loader diagnostics. */ export const name = 'tool-session-query' @@ -67,126 +44,6 @@ interface ResolvedConfig { readonly searchTimeoutMs: number } -interface SessionSearchArgs { - query: string - session_ids?: string[] - created_at_from?: string - created_at_to?: string - parent_session_ids?: string[] - include_root_sessions?: boolean - availability?: SessionAvailability[] - event_seq_from?: number - event_seq_to?: number - event_time_from?: string - event_time_to?: string - event_types?: string[] - event_surfaces?: SessionEventSurface[] -} - -interface EventSearchArgs { - session_id?: string - query: string - seq_from?: number - seq_to?: number - time_from?: string - time_to?: string - event_types?: string[] - surfaces?: SessionEventSurface[] -} - -interface SessionTargetArgs { - session_id?: string -} - -interface EventTargetArgs extends SessionTargetArgs { - seq: number -} - -interface EventReadArgs extends EventTargetArgs { - before?: number - after?: number -} - -interface Caller { - readonly id: SessionIdValue - readonly header: SessionHeader - readonly events: readonly SessionEvent[] -} - -interface TitleView { - readonly text: string - readonly unavailableCode?: string -} - -interface CompleteTitleMap extends ReadonlyMap { - get(id: SessionIdValue): TitleView -} - -interface SearchCollection { - readonly items: T[] - readonly capped: boolean -} - -interface AuthorizedDescendant { - readonly record: SessionRecord - readonly descendants: Array -} - -interface DescendantProjectionFrame { - readonly node: SessionLineageNode - readonly target: Array - readonly next: DescendantProjectionFrame | undefined -} - -interface DescendantVisit { - readonly node: AuthorizedDescendant | null - readonly depth: number - readonly next: DescendantVisit | undefined -} - -const SESSION_SEARCH_PARAMETERS = { - query: { type: 'string', required: true, description: 'Literal full-text query over prior session history.' }, - session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional session ids to include.' }, - created_at_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 creation-time lower bound.' }, - created_at_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 creation-time upper bound.' }, - parent_session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional direct parent session ids.' }, - include_root_sessions: { type: 'boolean', description: 'Include sessions with no parent in the parent filter.' }, - availability: { - type: 'array', - items: { type: 'string', enum: ['live', 'persisted'] }, - description: 'Require at least one selected source availability.', - }, - event_seq_from: { type: 'integer', description: 'Inclusive event sequence lower bound.' }, - event_seq_to: { type: 'integer', description: 'Inclusive event sequence upper bound.' }, - event_time_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time lower bound.' }, - event_time_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time upper bound.' }, - event_types: { type: 'array', items: { type: 'string' }, description: 'Event types to include.' }, - event_surfaces: { - type: 'array', - items: { type: 'string', enum: ['current', 'shadowed', 'log-only'] }, - description: 'Event surfaces to include.', - }, -} as const - -const EVENT_SEARCH_PARAMETERS = { - session_id: { type: 'string', description: 'Target session id. Omit for the current session.' }, - query: { type: 'string', required: true, description: 'Literal full-text query over the target session.' }, - seq_from: { type: 'integer', description: 'Inclusive event sequence lower bound.' }, - seq_to: { type: 'integer', description: 'Inclusive event sequence upper bound.' }, - time_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time lower bound.' }, - time_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time upper bound.' }, - event_types: { type: 'array', items: { type: 'string' }, description: 'Event types to include.' }, - surfaces: { - type: 'array', - items: { type: 'string', enum: ['current', 'shadowed', 'log-only'] }, - description: 'Event surfaces to include.', - }, -} as const - -const TARGET_SESSION_PARAMETER = { - session_id: { type: 'string', description: 'Target session id. Omit for the current session.' }, -} as const - const TEXT_OUTPUT = { schema: { type: 'string' as const }, render: (_args: unknown, value: string) => [{ type: 'text' as const, text: value }], @@ -197,76 +54,6 @@ const PROMPT_TEXT = + 'events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with ' + 'session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data.' -interface ModelSafeServiceFailure { - readonly code: SessionQueryErrorCode | 'SESSION_QUERY_TOOL_FAILED' - readonly message: string -} - -const UNPRINTABLE_SERVICE_ERROR = '[unprintable session query failure]' - -const SAFE_SESSION_QUERY_FAILURES = { - SESSION_QUERY_ABORTED: { - code: 'SESSION_QUERY_ABORTED', - message: 'session query was cancelled', - }, - SESSION_QUERY_EVENT_NOT_FOUND: { - code: 'SESSION_QUERY_EVENT_NOT_FOUND', - message: 'session event was not found', - }, - SESSION_QUERY_INDEX_FAILED: { - code: 'SESSION_QUERY_INDEX_FAILED', - message: 'session search index is unavailable', - }, - SESSION_QUERY_INVALID_CONFIG: { - code: 'SESSION_QUERY_TOOL_FAILED', - message: 'session query operation failed', - }, - SESSION_QUERY_INVALID_CURSOR: { - code: 'SESSION_QUERY_INVALID_CURSOR', - message: 'session search continuation is invalid', - }, - SESSION_QUERY_INVALID_FILTER: { - code: 'SESSION_QUERY_INVALID_FILTER', - message: 'session query filters were rejected', - }, - SESSION_QUERY_INVALID_LIMIT: { - code: 'SESSION_QUERY_INVALID_LIMIT', - message: 'session query result limit was rejected', - }, - SESSION_QUERY_INVALID_QUERY: { - code: 'SESSION_QUERY_INVALID_QUERY', - message: 'session query was rejected', - }, - SESSION_QUERY_INVALID_LINEAGE: { - code: 'SESSION_QUERY_INVALID_LINEAGE', - message: 'session lineage is invalid', - }, - SESSION_QUERY_INVALID_SURFACE: { - code: 'SESSION_QUERY_INVALID_SURFACE', - message: 'session event history is invalid', - }, - SESSION_QUERY_INVALID_WINDOW: { - code: 'SESSION_QUERY_INVALID_WINDOW', - message: 'session event window is invalid', - }, - SESSION_QUERY_PERSISTENCE_FAILED: { - code: 'SESSION_QUERY_PERSISTENCE_FAILED', - message: 'session history storage is unavailable', - }, - SESSION_QUERY_SESSION_NOT_FOUND: { - code: 'SESSION_QUERY_SESSION_NOT_FOUND', - message: 'session was not found', - }, - SESSION_QUERY_STALE_CURSOR: { - code: 'SESSION_QUERY_STALE_CURSOR', - message: 'session history changed while paging; retry the complete search call', - }, - SESSION_QUERY_SOURCE_CONFLICT: { - code: 'SESSION_QUERY_TOOL_FAILED', - message: 'session query operation failed', - }, -} satisfies Record - /** Register all five tools and their shared model guidance. */ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) @@ -279,59 +66,59 @@ export function apply(ctx: Context, config: Config): void { ctx.tools.register(defineTool({ name: 'session_search', description: 'Search prior sessions in the caller workspace and return the strongest matching event from each session.', - parameters: SESSION_SEARCH_PARAMETERS, + parameters: toolInput.sessionSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => executeSessionSearch(ctx, args, exec, resolved.maxSearchResults), - presentCall: presentSessionSearchCall, + execute: (args, exec) => operations.executeSessionSearch(ctx, args, exec, resolved.maxSearchResults), + presentCall: presentation.presentSessionSearchCall, })) ctx.tools.register(defineTool({ name: 'session_event_search', description: 'Search prior events in one authorized session; the current session excludes the step performing this call.', - parameters: EVENT_SEARCH_PARAMETERS, + parameters: toolInput.eventSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => executeEventSearch(ctx, args, exec, resolved.maxSearchResults), - presentCall: presentEventSearchCall, + execute: (args, exec) => operations.executeEventSearch(ctx, args, exec, resolved.maxSearchResults), + presentCall: presentation.presentEventSearchCall, })) ctx.tools.register(defineTool({ name: 'session_trace', description: 'Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.', - parameters: TARGET_SESSION_PARAMETER, + parameters: toolInput.targetSessionParameter, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => executeSessionTrace(ctx, args, exec), - presentCall: presentSessionTraceCall, + execute: (args, exec) => operations.executeSessionTrace(ctx, args, exec), + presentCall: presentation.presentSessionTraceCall, })) ctx.tools.register(defineTool({ name: 'session_event_trace', description: 'Read every direct replacement and provenance relationship for one event in an authorized session.', parameters: { - ...TARGET_SESSION_PARAMETER, + ...toolInput.targetSessionParameter, seq: { type: 'integer', required: true, description: 'Target event sequence number.' }, }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => executeEventTrace(ctx, args, exec), - presentCall: args => presentEventTargetCall('Trace event', args), + execute: (args, exec) => operations.executeEventTrace(ctx, args, exec), + presentCall: args => presentation.presentEventTargetCall('Trace event', args), })) ctx.tools.register(defineTool({ name: 'session_event_read', description: 'Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.', parameters: { - ...TARGET_SESSION_PARAMETER, + ...toolInput.targetSessionParameter, seq: { type: 'integer', required: true, description: 'Target event sequence number.' }, before: { type: 'integer', description: 'Number of preceding raw events to summarize. Omit for none.' }, after: { type: 'integer', description: 'Number of following raw events to summarize. Omit for none.' }, }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => executeEventRead(ctx, args, exec), - presentCall: args => presentEventTargetCall('Read event', args), + execute: (args, exec) => operations.executeEventRead(ctx, args, exec), + presentCall: args => presentation.presentEventTargetCall('Read event', args), })) } @@ -348,899 +135,3 @@ function resolveConfig(config: Config): ResolvedConfig { } return { maxSearchResults, searchTimeoutMs } } - -function callerOf(exec: ToolRunContext): Caller { - const agent = exec.agent - if (agent === undefined) { - throw new HarnessError( - 'session query tools require an agent-bound caller', - 'SESSION_QUERY_TOOL_MISSING_AGENT', - ) - } - return { - id: agent.session.id, - header: agent.session.header, - events: agent.session.events, - } -} - -function targetId(args: SessionTargetArgs, caller: Caller): SessionIdValue { - return args.session_id === undefined ? caller.id : SessionId(args.session_id) -} - -async function authorizeTarget( - ctx: Context, - caller: Caller, - target: SessionIdValue, - signal: AbortSignal, -): Promise { - if (target === caller.id) return - const cwd = caller.header.cwd - if (cwd === undefined) throw unauthorizedTarget() - const records = await sessionQueryCall(ctx, signal, 'target authorization', () => - ctx.sessionQuery.filterSessions([ - { kind: 'id', values: [target] }, - { kind: 'cwd', values: [cwd] }, - ], signal)) - if (records.length !== 1) throw unauthorizedTarget() -} - -function unauthorizedTarget(): HarnessError { - return new HarnessError( - 'session target is outside the caller workspace', - 'SESSION_QUERY_TOOL_UNAUTHORIZED', - ) -} - -async function sessionQueryCall( - ctx: Context, - signal: AbortSignal, - operation: string, - call: () => Promise, -): Promise { - signal.throwIfAborted() - try { - const value = await call() - signal.throwIfAborted() - return value - } catch (error: unknown) { - signal.throwIfAborted() - throw sanitizeSessionQueryError(ctx, operation, error) - } -} - -function sanitizeSessionQueryError( - ctx: Context, - operation: string, - error: unknown, -): HarnessError { - const generic = genericSessionQueryFailure() - const diagnostic = fullError(error) - try { - ctx.logger.warn(`tool-session-query: ${operation} failed: ${diagnostic}`) - if (error instanceof SessionQueryError) { - const code: unknown = error.code - const failure = typeof code === 'string' && Object.hasOwn(SAFE_SESSION_QUERY_FAILURES, code) - ? SAFE_SESSION_QUERY_FAILURES[code as SessionQueryErrorCode] - : undefined - if (failure !== undefined && failure.code !== 'SESSION_QUERY_TOOL_FAILED') { - return new SessionQueryError(failure.message, failure.code) - } - } - if (error instanceof HarnessError && error.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') { - return unauthorizedTarget() - } - } catch { - return generic - } - return generic -} - -function genericSessionQueryFailure(): HarnessError { - return new HarnessError( - 'session query operation failed', - 'SESSION_QUERY_TOOL_FAILED', - ) -} - -async function executeSessionSearch( - ctx: Context, - args: SessionSearchArgs, - exec: ToolRunContext, - maxResults: number, -): Promise { - const caller = callerOf(exec) - const cwd = caller.header.cwd - if (cwd === undefined) { - throw new HarnessError( - 'cross-session search is unavailable because the caller session has no workspace', - 'SESSION_QUERY_TOOL_UNAUTHORIZED', - ) - } - const query = normalizeQuery(args.query) - const sessionFilters = buildSessionFilters(args) - const eventFilters = buildEventFilters({ - seqFrom: args.event_seq_from, - seqTo: args.event_seq_to, - timeFrom: args.event_time_from, - timeTo: args.event_time_to, - eventTypes: args.event_types, - surfaces: args.event_surfaces, - }) - const requestedParentIds = materializeParentSessionIds(args.parent_session_ids) - if (requestedParentIds !== undefined || args.include_root_sessions === true) { - const authorizedParentIds = requestedParentIds === undefined - ? new Set() - : await authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal) - const parentValues: Array = requestedParentIds - ?.filter(id => authorizedParentIds.has(id)) ?? [] - if (args.include_root_sessions === true) parentValues.push(null) - if (parentValues.length === 0) return formatEmptySessionSearch() - sessionFilters.push({ kind: 'parent', values: parentValues }) - } - sessionFilters.push({ kind: 'cwd', values: [cwd] }) - const collected = await collectPages( - maxResults, - exec.signal, - cursor => sessionQueryCall(ctx, exec.signal, 'session search', () => - ctx.sessionQuery.searchSessions({ - query, - sessionFilters, - eventFilters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal })), - hit => hit.header.id !== caller.id && recordAuthorized(hit, caller), - ) - - const parentIds = collected.items - .map(hit => hit.header.parentSession) - .filter((id): id is SessionIdValue => id !== undefined) - const authorizedParents = await authorizeSessionIds(ctx, caller, parentIds, exec.signal) - const titles = await readTitles(ctx, caller, collected.items.map(hit => hit.header.id), exec.signal) - return formatSessionSearch(collected, titles, authorizedParents) -} - -async function executeEventSearch( - ctx: Context, - args: EventSearchArgs, - exec: ToolRunContext, - maxResults: number, -): Promise { - const caller = callerOf(exec) - const sessionId = targetId(args, caller) - await authorizeTarget(ctx, caller, sessionId, exec.signal) - const query = normalizeQuery(args.query) - const range = sequenceRange(args.seq_from, args.seq_to) - if (sessionId === caller.id) { - const stepStart = caller.events.findLast(event => event.type === 'step/start') - if (stepStart === undefined) { - throw new HarnessError( - 'current-session search requires an active step boundary', - 'SESSION_QUERY_TOOL_NO_CURRENT_STEP', - ) - } - range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, stepStart.seq - 1) - } - const title = await readTitle(ctx, caller, sessionId, exec.signal) - if (range.from !== undefined && range.to !== undefined && range.from > range.to) { - return formatEventSearch(sessionId, title, { items: [], capped: false }) - } - const filters = buildEventFilters({ - seqFrom: range.from, - seqTo: range.to, - timeFrom: args.time_from, - timeTo: args.time_to, - eventTypes: args.event_types, - surfaces: args.surfaces, - }) - const collected = await collectPages( - maxResults, - exec.signal, - async (cursor): Promise => { - const page = await sessionQueryCall(ctx, exec.signal, 'event search', () => - ctx.sessionQuery.searchEvents({ - sessionId, - query, - filters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal })) - assertObservedTargetAuthorized(caller, sessionId, page.session) - return page - }, - () => true, - ) - return formatEventSearch(sessionId, title, collected) -} - -async function executeSessionTrace( - ctx: Context, - args: SessionTargetArgs, - exec: ToolRunContext, -): Promise { - const caller = callerOf(exec) - const sessionId = targetId(args, caller) - await authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await sessionQueryCall(ctx, exec.signal, 'session lineage trace', () => - ctx.sessionQuery.traceSession(sessionId, exec.signal)) - assertObservedTargetAuthorized(caller, sessionId, trace.target.header) - - const ancestors: SessionRecord[] = [] - let ancestorBoundary = false - for (const ancestor of trace.ancestors) { - if (!recordAuthorized(ancestor, caller)) { - ancestorBoundary = true - break - } - ancestors.push(ancestor) - } - if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true - const descendants = authorizeDescendants(trace.descendants, caller) - const visibleIds = [ - trace.target.header.id, - ...ancestors.map(record => record.header.id), - ...descendantIds(descendants), - ] - const titles = await readTitles(ctx, caller, visibleIds, exec.signal) - return formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) -} - -async function executeEventTrace( - ctx: Context, - args: EventTargetArgs, - exec: ToolRunContext, -): Promise { - assertNonNegativeSafeInteger('seq', args.seq) - const caller = callerOf(exec) - const sessionId = targetId(args, caller) - await authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await sessionQueryCall(ctx, exec.signal, 'event trace', () => - ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }, exec.signal)) - assertObservedTargetAuthorized(caller, sessionId, trace.session) - const title = await readTitle(ctx, caller, sessionId, exec.signal) - return formatEventTrace(sessionId, title, trace) -} - -async function executeEventRead( - ctx: Context, - args: EventReadArgs, - exec: ToolRunContext, -): Promise { - assertNonNegativeSafeInteger('seq', args.seq) - if (args.before !== undefined) assertNonNegativeSafeInteger('before', args.before) - if (args.after !== undefined) assertNonNegativeSafeInteger('after', args.after) - const caller = callerOf(exec) - const sessionId = targetId(args, caller) - await authorizeTarget(ctx, caller, sessionId, exec.signal) - const window = await sessionQueryCall(ctx, exec.signal, 'event read', () => - ctx.sessionQuery.readEvent({ - sessionId, - seq: args.seq, - ...args.before === undefined ? {} : { before: args.before }, - ...args.after === undefined ? {} : { after: args.after }, - }, exec.signal)) - assertObservedTargetAuthorized(caller, sessionId, window.session) - const title = await readTitle(ctx, caller, sessionId, exec.signal) - return formatEventRead(sessionId, title, window) -} - -function buildSessionFilters(args: SessionSearchArgs): SessionResultFilter[] { - const filters: SessionResultFilter[] = [] - if (args.session_ids !== undefined) { - assertNonEmptyArray('session_ids', args.session_ids) - filters.push({ kind: 'id', values: args.session_ids.map(SessionId) }) - } - const created = timestampRange('created_at', args.created_at_from, args.created_at_to) - if (created !== undefined) filters.push({ kind: 'created-at', ...created }) - if (args.availability !== undefined) { - assertNonEmptyArray('availability', args.availability) - filters.push({ kind: 'availability', values: args.availability }) - } - return filters -} - -function materializeParentSessionIds(values: readonly string[] | undefined): SessionIdValue[] | undefined { - if (values === undefined) return undefined - assertNonEmptyArray('parent_session_ids', values) - return [...new Set(values.map(SessionId))] -} - -interface EventFilterInput { - readonly seqFrom?: number | undefined - readonly seqTo?: number | undefined - readonly timeFrom?: string | undefined - readonly timeTo?: string | undefined - readonly eventTypes?: string[] | undefined - readonly surfaces?: SessionEventSurface[] | undefined -} - -function buildEventFilters(input: EventFilterInput): SessionEventMetadataFilter[] { - const filters: SessionEventMetadataFilter[] = [] - const seq = sequenceRange(input.seqFrom, input.seqTo) - if (seq.from !== undefined || seq.to !== undefined) filters.push({ kind: 'seq', ...seq }) - const time = timestampRange('time', input.timeFrom, input.timeTo) - if (time !== undefined) filters.push({ kind: 'time', ...time }) - if (input.eventTypes !== undefined) { - assertNonEmptyArray('event_types', input.eventTypes) - filters.push({ kind: 'type', values: input.eventTypes as SessionEventType[] }) - } - if (input.surfaces !== undefined) { - assertNonEmptyArray('surfaces', input.surfaces) - filters.push({ kind: 'surface', values: input.surfaces }) - } - return filters -} - -function normalizeQuery(value: string): string { - const query = value.trim().replace(/\s+/gu, ' ') - if (query.length === 0) { - throw new SessionQueryError( - 'session-search query must contain non-whitespace text', - 'SESSION_QUERY_INVALID_QUERY', - ) - } - if (query.includes('\0')) { - throw new SessionQueryError( - 'session-search query must not contain NUL', - 'SESSION_QUERY_INVALID_QUERY', - ) - } - return query -} - -function sequenceRange( - from: number | undefined, - to: number | undefined, -): { from?: number; to?: number } { - if (from !== undefined) assertNonNegativeSafeInteger('sequence lower bound', from) - if (to !== undefined) assertNonNegativeSafeInteger('sequence upper bound', to) - if (from !== undefined && to !== undefined && from > to) { - throw invalidRange('sequence', 'from must be less than or equal to to') - } - return { - ...from === undefined ? {} : { from }, - ...to === undefined ? {} : { to }, - } -} - -function timestampRange( - name: string, - from: string | undefined, - to: string | undefined, -): { from?: number; to?: number } | undefined { - if (from === undefined && to === undefined) return undefined - const fromTimestamp = from === undefined ? undefined : parseIsoTimestamp(`${name}_from`, from) - const toTimestamp = to === undefined ? undefined : parseIsoTimestamp(`${name}_to`, to) - if ( - fromTimestamp !== undefined - && toTimestamp !== undefined - && compareTimestamps(fromTimestamp, toTimestamp) > 0 - ) { - throw invalidRange(name, 'from must be less than or equal to to') - } - return { - ...fromTimestamp === undefined ? {} : { from: timestampLowerBound(fromTimestamp) }, - ...toTimestamp === undefined ? {} : { to: timestampUpperBound(toTimestamp) }, - } -} - -const ISO_TIMESTAMP = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|([+-])(\d{2}):(\d{2}))$/ - -interface ExactTimestamp { - readonly millisecond: number - /** Canonical decimal digits strictly below one millisecond; no trailing zeroes. */ - readonly remainder: string -} - -function parseIsoTimestamp(name: string, value: string): ExactTimestamp { - const match = ISO_TIMESTAMP.exec(value) - if (match === null) { - throw invalidRange(name, 'must be an ISO 8601 timestamp with Z or a numeric offset') - } - const year = Number(match[1]) - const month = Number(match[2]) - const day = Number(match[3]) - const hour = Number(match[4]) - const minute = Number(match[5]) - const second = Number(match[6] ?? 0) - const offsetHour = Number(match[10] ?? 0) - const offsetMinute = Number(match[11] ?? 0) - if ( - month < 1 || month > 12 - || day < 1 || day > daysInMonth(year, month) - || hour > 23 || minute > 59 || second > 59 - || offsetHour > 23 || offsetMinute > 59 - ) { - throw invalidRange(name, 'must be a valid ISO 8601 timestamp') - } - const fraction = match[7] ?? '' - const millisecondDigits = fraction.slice(0, 3).padEnd(3, '0') - const normalized = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}` - + `:${match[6] ?? '00'}.${millisecondDigits}${match[8]}` - const timestamp = Date.parse(normalized) - if (!Number.isSafeInteger(timestamp)) { - throw invalidRange(name, 'must be a valid ISO 8601 timestamp') - } - return { - millisecond: timestamp, - remainder: fraction.slice(3).replace(/0+$/u, ''), - } -} - -function compareTimestamps(left: ExactTimestamp, right: ExactTimestamp): number { - if (left.millisecond !== right.millisecond) { - return left.millisecond < right.millisecond ? -1 : 1 - } - const length = Math.max(left.remainder.length, right.remainder.length) - for (let index = 0; index < length; index += 1) { - const leftDigit = left.remainder[index] ?? '0' - const rightDigit = right.remainder[index] ?? '0' - if (leftDigit !== rightDigit) return leftDigit < rightDigit ? -1 : 1 - } - return 0 -} - -function timestampLowerBound(timestamp: ExactTimestamp): number { - return timestamp.remainder.length === 0 - ? timestamp.millisecond - : nextUpFinite(timestamp.millisecond) -} - -function timestampUpperBound(timestamp: ExactTimestamp): number { - return timestamp.remainder.length === 0 - ? timestamp.millisecond - : nextDownFinite(timestamp.millisecond + 1) -} - -/** Return the adjacent IEEE-754 value toward positive infinity for a finite input. */ -function nextUpFinite(value: number): number { - if (value === 0) return Number.MIN_VALUE - const view = new DataView(new ArrayBuffer(8)) - view.setFloat64(0, value) - const bits = view.getBigUint64(0) - view.setBigUint64(0, value > 0 ? bits + 1n : bits - 1n) - return view.getFloat64(0) -} - -/** Return the adjacent IEEE-754 value toward negative infinity for a finite input. */ -function nextDownFinite(value: number): number { - if (value === 0) return -Number.MIN_VALUE - const view = new DataView(new ArrayBuffer(8)) - view.setFloat64(0, value) - const bits = view.getBigUint64(0) - view.setBigUint64(0, value > 0 ? bits - 1n : bits + 1n) - return view.getFloat64(0) -} - -function daysInMonth(year: number, month: number): number { - if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28 - return [4, 6, 9, 11].includes(month) ? 30 : 31 -} - -function invalidRange(name: string, detail: string): SessionQueryError { - return new SessionQueryError( - `session ${name} range ${detail}`, - 'SESSION_QUERY_INVALID_FILTER', - ) -} - -function assertNonNegativeSafeInteger(name: string, value: number): void { - if (!Number.isSafeInteger(value) || value < 0) { - throw new SessionQueryError( - `${name} must be a non-negative safe integer`, - 'SESSION_QUERY_INVALID_FILTER', - ) - } -} - -function assertNonEmptyArray(name: string, values: readonly unknown[]): void { - if (values.length === 0) { - throw new SessionQueryError( - `${name} must contain at least one value when supplied`, - 'SESSION_QUERY_INVALID_FILTER', - ) - } -} - -async function collectPages( - maxResults: number, - signal: AbortSignal, - request: (cursor?: SessionSearchCursor) => Promise<{ - readonly items: readonly T[] - readonly nextCursor?: SessionSearchCursor - }>, - accept: (item: T) => boolean, -): Promise> { - const items: T[] = [] - const seen = new Set() - let cursor: SessionSearchCursor | undefined - while (true) { - signal.throwIfAborted() - const page = await request(cursor) - signal.throwIfAborted() - for (const item of page.items) { - if (!accept(item)) continue - if (items.length === maxResults) { - return { items, capped: true } - } - items.push(item) - } - if (page.nextCursor === undefined) return { items, capped: false } - if (seen.has(page.nextCursor)) { - throw new SessionQueryError( - 'session-search provider repeated a continuation cursor', - 'SESSION_QUERY_INVALID_CURSOR', - ) - } - seen.add(page.nextCursor) - cursor = page.nextCursor - } -} - -function recordAuthorized(record: SessionRecord, caller: Caller): boolean { - return headerAuthorized(record.header, caller) -} - -function headerAuthorized(header: SessionHeader, caller: Caller): boolean { - if (header.id === caller.id) return header.cwd === caller.header.cwd - return caller.header.cwd !== undefined && header.cwd === caller.header.cwd -} - -function assertObservedTargetAuthorized( - caller: Caller, - target: SessionIdValue, - observed: SessionHeader, -): void { - if (observed.id !== target || !headerAuthorized(observed, caller)) throw unauthorizedTarget() -} - -async function authorizeSessionIds( - ctx: Context, - caller: Caller, - ids: readonly SessionIdValue[], - signal: AbortSignal, -): Promise> { - const unique = [...new Set(ids)] - const authorized = new Set() - if (unique.includes(caller.id)) authorized.add(caller.id) - const cwd = caller.header.cwd - const other = unique.filter(id => id !== caller.id) - if (cwd === undefined || other.length === 0) return authorized - const records = await sessionQueryCall(ctx, signal, 'session-id authorization', () => - ctx.sessionQuery.filterSessions([ - { kind: 'id', values: other }, - { kind: 'cwd', values: [cwd] }, - ], signal)) - const requested = new Set(other) - for (const record of records) { - if (requested.has(record.header.id) && recordAuthorized(record, caller)) { - authorized.add(record.header.id) - } - } - return authorized -} - -async function readTitles( - ctx: Context, - caller: Caller, - ids: readonly SessionIdValue[], - signal: AbortSignal, -): Promise { - const result = new Map() - const observations = await sessionQueryCall(ctx, signal, 'title observation', () => - ctx.sessionQuery.readTitleSnapshots(ids, signal)) - for (const observation of observations) { - if (observation.status === 'rejected') { - result.set(observation.sessionId, unavailableTitle(ctx, observation.reason)) - continue - } - assertObservedTargetAuthorized(caller, observation.sessionId, observation.value.session) - result.set(observation.sessionId, { text: observation.value.title?.title ?? 'untitled' }) - } - return result as CompleteTitleMap -} - -async function readTitle( - ctx: Context, - caller: Caller, - id: SessionIdValue, - signal: AbortSignal, -): Promise { - return (await readTitles(ctx, caller, [id], signal)).get(id) -} - -function unavailableTitle( - ctx: Context, - error: unknown, -): TitleView { - const sanitized = sanitizeSessionQueryError(ctx, 'title observation item', error) - if (sanitized.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw sanitized - return { text: 'untitled', unavailableCode: sanitized.code } -} - -function fullError(error: unknown): string { - try { - return renderFullError(error) - } catch { - return UNPRINTABLE_SERVICE_ERROR - } -} - -function renderFullError(error: unknown): string { - if (!(error instanceof Error)) return String(error) - const diagnostics: string[] = [] - const seen = new Set() - let current: unknown = error - while (current instanceof Error && !seen.has(current)) { - seen.add(current) - diagnostics.push(current.stack ?? String(current)) - current = current.cause - } - /* v8 ignore next -- defensive containment for a cyclic Error.cause graph */ - if (current instanceof Error) diagnostics.push('[circular error cause]') - else if (current !== undefined) diagnostics.push(renderFullError(current)) - return diagnostics.join('\nCaused by: ') -} - -function authorizeDescendants( - nodes: readonly SessionLineageNode[], - caller: Caller, -): Array { - const result: Array = [] - let pending: DescendantProjectionFrame | undefined - for (const node of [...nodes].reverse()) { - pending = { node, target: result, next: pending } - } - while (pending !== undefined) { - const current = pending - pending = current.next - if (!recordAuthorized(current.node.session, caller)) { - current.target.push(null) - continue - } - const projected: AuthorizedDescendant = { - record: current.node.session, - descendants: [], - } - current.target.push(projected) - for (const child of [...current.node.descendants].reverse()) { - pending = { - node: child, - target: projected.descendants, - next: pending, - } - } - } - return result -} - -function * visitDescendants( - nodes: readonly (AuthorizedDescendant | null)[], -): Generator { - let pending: DescendantVisit | undefined - for (const node of [...nodes].reverse()) { - pending = { node, depth: 0, next: pending } - } - while (pending !== undefined) { - const current = pending - pending = current.next - yield current - if (current.node === null) continue - for (const child of [...current.node.descendants].reverse()) { - pending = { - node: child, - depth: current.depth + 1, - next: pending, - } - } - } -} - -function descendantIds(nodes: readonly (AuthorizedDescendant | null)[]): SessionIdValue[] { - const ids: SessionIdValue[] = [] - for (const { node } of visitDescendants(nodes)) { - if (node !== null) ids.push(node.record.header.id) - } - return ids -} - -function titleText(view: TitleView): string { - return view.unavailableCode === undefined - ? view.text - : `${view.text} (title unavailable: ${view.unavailableCode})` -} - -function formatSessionSearch( - collected: SearchCollection, - titles: CompleteTitleMap, - authorizedParents: ReadonlySet, -): string { - if (collected.items.length === 0) return formatEmptySessionSearch() - const lines = [`Session search results (${collected.items.length}):`] - for (const [index, hit] of collected.items.entries()) { - const parent = hit.header.parentSession === undefined - ? 'root' - : authorizedParents.has(hit.header.parentSession) - ? hit.header.parentSession - : '[outside workspace]' - const availability = [ - hit.live ? 'live' : undefined, - hit.persisted ? 'persisted' : undefined, - ].filter((value): value is string => value !== undefined).join(', ') || 'unavailable' - lines.push( - '', - `${index + 1}. Session ${hit.header.id} — ${titleText(titles.get(hit.header.id))}`, - ` Created: ${formatTime(hit.header.createdAt)}`, - ` Parent: ${parent}`, - ` Availability: ${availability}`, - ` Best match: seq ${hit.bestMatch.seq} | ${hit.bestMatch.type} | ${hit.bestMatch.surface} | ${formatTime(hit.bestMatch.time)}`, - ` Snippet: ${hit.bestMatch.snippet}`, - ) - } - if (collected.capped) { - lines.push('', 'Result cap reached. Narrow the query or add filters to find additional matches.') - } - return lines.join('\n') -} - -function formatEmptySessionSearch(): string { - return 'No prior session matches found.' -} - -function formatEventSearch( - sessionId: SessionIdValue, - title: TitleView, - collected: SearchCollection, -): string { - const lines = [`Session ${sessionId} — ${titleText(title)}`] - if (collected.items.length === 0) { - lines.push('', 'No prior event matches found.') - return lines.join('\n') - } - lines.push('', `Event search results (${collected.items.length}):`) - for (const [index, hit] of collected.items.entries()) { - lines.push( - `${index + 1}. seq ${hit.seq} | ${hit.type} | ${hit.surface} | ${formatTime(hit.time)}`, - ` Snippet: ${hit.snippet}`, - ) - } - if (collected.capped) { - lines.push('', 'Result cap reached. Narrow the query or add filters to find additional matches.') - } - return lines.join('\n') -} - -function formatSessionTrace( - trace: SessionLineageTrace, - ancestors: readonly SessionRecord[], - ancestorBoundary: boolean, - descendants: readonly (AuthorizedDescendant | null)[], - titles: CompleteTitleMap, -): string { - const lines = [ - `Session ${trace.target.header.id} — ${titleText(titles.get(trace.target.header.id))}`, - `Created: ${formatTime(trace.target.header.createdAt)}`, - `Availability: ${availabilityText(trace.target)}`, - '', - 'Ancestors (nearest first):', - ] - if (ancestors.length === 0 && !ancestorBoundary) lines.push('- none (target is a root session)') - for (const record of ancestors) { - lines.push(`- ${record.header.id} — ${titleText(titles.get(record.header.id))} | ${formatTime(record.header.createdAt)} | ${availabilityText(record)}`) - } - if (ancestorBoundary) lines.push('- [outside workspace boundary]') - lines.push('', 'Descendants:') - if (descendants.length === 0) lines.push('- none') - else renderDescendants(lines, descendants, titles) - return lines.join('\n') -} - -function renderDescendants( - lines: string[], - nodes: readonly (AuthorizedDescendant | null)[], - titles: CompleteTitleMap, -): void { - for (const { node, depth } of visitDescendants(nodes)) { - const indent = ' '.repeat(depth) - if (node === null) { - lines.push(`${indent}- [outside workspace subtree]`) - continue - } - const id = node.record.header.id - lines.push(`${indent}- ${id} — ${titleText(titles.get(id))} | ${formatTime(node.record.header.createdAt)} | ${availabilityText(node.record)}`) - } -} - -function formatEventTrace( - sessionId: SessionIdValue, - title: TitleView, - trace: SessionEventTraceObservation, -): string { - return [ - `Session ${sessionId} — ${titleText(title)}`, - `Target: seq ${trace.target.seq} | ${trace.target.type} | ${trace.target.surface} | ${formatTime(trace.target.time)}`, - `Replaced by: ${trace.replacedBy ?? 'none'}`, - `Replacement chain: ${seqList(trace.replacementChain)}`, - `Events replaced by target: ${seqList(trace.replacedEventSeqs)}`, - `Direct provenance sources: ${seqList(trace.sourceEventSeqs)}`, - `Direct derived events: ${seqList(trace.derivedEventSeqs)}`, - ].join('\n') -} - -function formatEventRead( - sessionId: SessionIdValue, - title: TitleView, - window: SessionEventWindow, -): string { - const before = window.events.filter(event => event.seq < window.target.seq) - const after = window.events.filter(event => event.seq > window.target.seq) - const lines = [ - `Session ${sessionId} — ${titleText(title)}`, - `Target event seq ${window.target.seq}:`, - '```json', - JSON.stringify(window.target, null, 2), - '```', - ] - if (before.length > 0) { - lines.push('', 'Before:') - for (const event of before) lines.push(formatNeighbor(event)) - } - if (after.length > 0) { - lines.push('', 'After:') - for (const event of after) lines.push(formatNeighbor(event)) - } - return lines.join('\n') -} - -function formatNeighbor(event: SessionEvent): string { - const text = extractSessionEventText(event) - return `- seq ${event.seq} | ${event.type} | ${formatTime(event.time)}` - + (text.length === 0 ? ' | (no semantic text)' : `\n ${text.replaceAll('\n', '\n ')}`) -} - -function availabilityText(record: SessionRecord): string { - return [ - record.live ? 'live' : undefined, - record.persisted ? 'persisted' : undefined, - ].filter((value): value is string => value !== undefined).join(', ') || 'unavailable' -} - -function seqList(values: readonly number[]): string { - return values.length === 0 ? 'none' : values.join(', ') -} - -function formatTime(value: number): string { - return new Date(value).toISOString() -} - -function presentSessionSearchCall(args: SessionSearchArgs): GenericCallView { - return { card: 'generic', kind: 'search', title: 'Search prior sessions', rawInput: args.query } -} - -function presentEventSearchCall(args: EventSearchArgs): GenericCallView { - return { card: 'generic', kind: 'search', title: 'Search session events', rawInput: args.query } -} - -function presentSessionTraceCall(args: SessionTargetArgs): GenericCallView { - return { - card: 'generic', - kind: 'read', - title: args.session_id === undefined ? 'Trace current session' : `Trace session ${args.session_id}`, - ...args.session_id === undefined ? {} : { rawInput: args.session_id }, - } -} - -function presentEventTargetCall( - action: string, - args: EventTargetArgs, -): GenericCallView { - return { - card: 'generic', - kind: 'read', - title: `${action} ${args.seq}`, - rawInput: { - ...args.session_id === undefined ? {} : { session_id: args.session_id }, - seq: args.seq, - }, - } -} diff --git a/packages/session-query/tool-session-query/src/input.ts b/packages/session-query/tool-session-query/src/input.ts new file mode 100644 index 0000000000..4b045ea72d --- /dev/null +++ b/packages/session-query/tool-session-query/src/input.ts @@ -0,0 +1,307 @@ +/** + * Model argument schemas, normalization, and filter construction. + * + * @module @deepseek-ai/dsh-tool-session-query/input + */ + +import { + SessionId, + type SessionEventType, + type SessionId as SessionIdValue, +} from '@deepseek-ai/dsh-session' +import { + SessionQueryError, + type SessionAvailability, + type SessionEventMetadataFilter, + type SessionEventSurface, + type SessionResultFilter, +} from '@deepseek-ai/dsh-session-query' + +interface SessionSearchArgs { + query: string + session_ids?: string[] + created_at_from?: string + created_at_to?: string + parent_session_ids?: string[] + include_root_sessions?: boolean + availability?: SessionAvailability[] + event_seq_from?: number + event_seq_to?: number + event_time_from?: string + event_time_to?: string + event_types?: string[] + event_surfaces?: SessionEventSurface[] +} + +interface EventFilterInput { + readonly seqFrom?: number | undefined + readonly seqTo?: number | undefined + readonly timeFrom?: string | undefined + readonly timeTo?: string | undefined + readonly eventTypes?: string[] | undefined + readonly surfaces?: SessionEventSurface[] | undefined +} + +const sessionSearchParameters = { + query: { type: 'string', required: true, description: 'Literal full-text query over prior session history.' }, + session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional session ids to include.' }, + created_at_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 creation-time lower bound.' }, + created_at_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 creation-time upper bound.' }, + parent_session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional direct parent session ids.' }, + include_root_sessions: { type: 'boolean', description: 'Include sessions with no parent in the parent filter.' }, + availability: { + type: 'array', + items: { type: 'string', enum: ['live', 'persisted'] }, + description: 'Require at least one selected source availability.', + }, + event_seq_from: { type: 'integer', description: 'Inclusive event sequence lower bound.' }, + event_seq_to: { type: 'integer', description: 'Inclusive event sequence upper bound.' }, + event_time_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time lower bound.' }, + event_time_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time upper bound.' }, + event_types: { type: 'array', items: { type: 'string' }, description: 'Event types to include.' }, + event_surfaces: { + type: 'array', + items: { type: 'string', enum: ['current', 'shadowed', 'log-only'] }, + description: 'Event surfaces to include.', + }, +} as const + +const eventSearchParameters = { + session_id: { type: 'string', description: 'Target session id. Omit for the current session.' }, + query: { type: 'string', required: true, description: 'Literal full-text query over the target session.' }, + seq_from: { type: 'integer', description: 'Inclusive event sequence lower bound.' }, + seq_to: { type: 'integer', description: 'Inclusive event sequence upper bound.' }, + time_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time lower bound.' }, + time_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time upper bound.' }, + event_types: { type: 'array', items: { type: 'string' }, description: 'Event types to include.' }, + surfaces: { + type: 'array', + items: { type: 'string', enum: ['current', 'shadowed', 'log-only'] }, + description: 'Event surfaces to include.', + }, +} as const + +const targetSessionParameter = { + session_id: { type: 'string', description: 'Target session id. Omit for the current session.' }, +} as const + +function buildSessionFilters(args: SessionSearchArgs): SessionResultFilter[] { + const filters: SessionResultFilter[] = [] + if (args.session_ids !== undefined) { + assertNonEmptyArray('session_ids', args.session_ids) + filters.push({ kind: 'id', values: args.session_ids.map(SessionId) }) + } + const created = timestampRange('created_at', args.created_at_from, args.created_at_to) + if (created !== undefined) filters.push({ kind: 'created-at', ...created }) + if (args.availability !== undefined) { + assertNonEmptyArray('availability', args.availability) + filters.push({ kind: 'availability', values: args.availability }) + } + return filters +} + +function materializeParentSessionIds(values: readonly string[] | undefined): SessionIdValue[] | undefined { + if (values === undefined) return undefined + assertNonEmptyArray('parent_session_ids', values) + return [...new Set(values.map(SessionId))] +} + +function buildEventFilters(input: EventFilterInput): SessionEventMetadataFilter[] { + const filters: SessionEventMetadataFilter[] = [] + const seq = sequenceRange(input.seqFrom, input.seqTo) + if (seq.from !== undefined || seq.to !== undefined) filters.push({ kind: 'seq', ...seq }) + const time = timestampRange('time', input.timeFrom, input.timeTo) + if (time !== undefined) filters.push({ kind: 'time', ...time }) + if (input.eventTypes !== undefined) { + assertNonEmptyArray('event_types', input.eventTypes) + filters.push({ kind: 'type', values: input.eventTypes as SessionEventType[] }) + } + if (input.surfaces !== undefined) { + assertNonEmptyArray('surfaces', input.surfaces) + filters.push({ kind: 'surface', values: input.surfaces }) + } + return filters +} + +function normalizeQuery(value: string): string { + const query = value.trim().replace(/\s+/gu, ' ') + if (query.length === 0) { + throw new SessionQueryError( + 'session-search query must contain non-whitespace text', + 'SESSION_QUERY_INVALID_QUERY', + ) + } + if (query.includes('\0')) { + throw new SessionQueryError( + 'session-search query must not contain NUL', + 'SESSION_QUERY_INVALID_QUERY', + ) + } + return query +} + +function sequenceRange( + from: number | undefined, + to: number | undefined, +): { from?: number; to?: number } { + if (from !== undefined) assertNonNegativeSafeInteger('sequence lower bound', from) + if (to !== undefined) assertNonNegativeSafeInteger('sequence upper bound', to) + if (from !== undefined && to !== undefined && from > to) { + throw invalidRange('sequence', 'from must be less than or equal to to') + } + return { + ...from === undefined ? {} : { from }, + ...to === undefined ? {} : { to }, + } +} + +function timestampRange( + name: string, + from: string | undefined, + to: string | undefined, +): { from?: number; to?: number } | undefined { + if (from === undefined && to === undefined) return undefined + const fromTimestamp = from === undefined ? undefined : parseIsoTimestamp(`${name}_from`, from) + const toTimestamp = to === undefined ? undefined : parseIsoTimestamp(`${name}_to`, to) + if ( + fromTimestamp !== undefined + && toTimestamp !== undefined + && compareTimestamps(fromTimestamp, toTimestamp) > 0 + ) { + throw invalidRange(name, 'from must be less than or equal to to') + } + return { + ...fromTimestamp === undefined ? {} : { from: timestampLowerBound(fromTimestamp) }, + ...toTimestamp === undefined ? {} : { to: timestampUpperBound(toTimestamp) }, + } +} + +const ISO_TIMESTAMP = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|([+-])(\d{2}):(\d{2}))$/ + +interface ExactTimestamp { + readonly millisecond: number + /** Canonical decimal digits strictly below one millisecond; no trailing zeroes. */ + readonly remainder: string +} + +function parseIsoTimestamp(name: string, value: string): ExactTimestamp { + const match = ISO_TIMESTAMP.exec(value) + if (match === null) { + throw invalidRange(name, 'must be an ISO 8601 timestamp with Z or a numeric offset') + } + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const hour = Number(match[4]) + const minute = Number(match[5]) + const second = Number(match[6] ?? 0) + const offsetHour = Number(match[10] ?? 0) + const offsetMinute = Number(match[11] ?? 0) + if ( + month < 1 || month > 12 + || day < 1 || day > daysInMonth(year, month) + || hour > 23 || minute > 59 || second > 59 + || offsetHour > 23 || offsetMinute > 59 + ) { + throw invalidRange(name, 'must be a valid ISO 8601 timestamp') + } + const fraction = match[7] ?? '' + const millisecondDigits = fraction.slice(0, 3).padEnd(3, '0') + const normalized = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}` + + `:${match[6] ?? '00'}.${millisecondDigits}${match[8]}` + const timestamp = Date.parse(normalized) + if (!Number.isSafeInteger(timestamp)) { + throw invalidRange(name, 'must be a valid ISO 8601 timestamp') + } + return { + millisecond: timestamp, + remainder: fraction.slice(3).replace(/0+$/u, ''), + } +} + +function compareTimestamps(left: ExactTimestamp, right: ExactTimestamp): number { + if (left.millisecond !== right.millisecond) { + return left.millisecond < right.millisecond ? -1 : 1 + } + const length = Math.max(left.remainder.length, right.remainder.length) + for (let index = 0; index < length; index += 1) { + const leftDigit = left.remainder[index] ?? '0' + const rightDigit = right.remainder[index] ?? '0' + if (leftDigit !== rightDigit) return leftDigit < rightDigit ? -1 : 1 + } + return 0 +} + +function timestampLowerBound(timestamp: ExactTimestamp): number { + return timestamp.remainder.length === 0 + ? timestamp.millisecond + : nextUpFinite(timestamp.millisecond) +} + +function timestampUpperBound(timestamp: ExactTimestamp): number { + return timestamp.remainder.length === 0 + ? timestamp.millisecond + : nextDownFinite(timestamp.millisecond + 1) +} + +function nextUpFinite(value: number): number { + if (value === 0) return Number.MIN_VALUE + const view = new DataView(new ArrayBuffer(8)) + view.setFloat64(0, value) + const bits = view.getBigUint64(0) + view.setBigUint64(0, value > 0 ? bits + 1n : bits - 1n) + return view.getFloat64(0) +} + +function nextDownFinite(value: number): number { + if (value === 0) return -Number.MIN_VALUE + const view = new DataView(new ArrayBuffer(8)) + view.setFloat64(0, value) + const bits = view.getBigUint64(0) + view.setBigUint64(0, value > 0 ? bits - 1n : bits + 1n) + return view.getFloat64(0) +} + +function daysInMonth(year: number, month: number): number { + if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28 + return [4, 6, 9, 11].includes(month) ? 30 : 31 +} + +function invalidRange(name: string, detail: string): SessionQueryError { + return new SessionQueryError( + `session ${name} range ${detail}`, + 'SESSION_QUERY_INVALID_FILTER', + ) +} + +function assertNonNegativeSafeInteger(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new SessionQueryError( + `${name} must be a non-negative safe integer`, + 'SESSION_QUERY_INVALID_FILTER', + ) + } +} + +function assertNonEmptyArray(name: string, values: readonly unknown[]): void { + if (values.length === 0) { + throw new SessionQueryError( + `${name} must contain at least one value when supplied`, + 'SESSION_QUERY_INVALID_FILTER', + ) + } +} + +/** Model schemas and model-owned value normalization shared by tool operations. */ +export const toolInput = { + sessionSearchParameters, + eventSearchParameters, + targetSessionParameter, + buildSessionFilters, + materializeParentSessionIds, + buildEventFilters, + normalizeQuery, + sequenceRange, + assertNonNegativeSafeInteger, +} diff --git a/packages/session-query/tool-session-query/src/operations.ts b/packages/session-query/tool-session-query/src/operations.ts new file mode 100644 index 0000000000..f169842823 --- /dev/null +++ b/packages/session-query/tool-session-query/src/operations.ts @@ -0,0 +1,281 @@ +/** + * Tool operation orchestration over session-query service capabilities. + * + * @module @deepseek-ai/dsh-tool-session-query/operations + */ + +import type { Context } from 'cordis' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { + SessionQueryError, + type SessionEventSearchPage, + type SessionEventSurface, + type SessionRecord, + type SessionSearchCursor, +} from '@deepseek-ai/dsh-session-query' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import { toolInput } from './input.ts' +import { presentation } from './presentation.ts' +import { serviceBoundary } from './service-boundary.ts' +import { workspaceAccess } from './workspace-access.ts' + +type SessionSearchArgs = Parameters[0] + +interface EventSearchArgs { + session_id?: string + query: string + seq_from?: number + seq_to?: number + time_from?: string + time_to?: string + event_types?: string[] + surfaces?: SessionEventSurface[] +} + +interface SessionTargetArgs { + session_id?: string +} + +interface EventTargetArgs extends SessionTargetArgs { + seq: number +} + +interface EventReadArgs extends EventTargetArgs { + before?: number + after?: number +} + +interface SearchCollection { + readonly items: T[] + readonly capped: boolean +} + +async function executeSessionSearch( + ctx: Context, + args: SessionSearchArgs, + exec: ToolRunContext, + maxResults: number, +): Promise { + const caller = workspaceAccess.callerOf(exec) + const cwd = caller.header.cwd + if (cwd === undefined) { + throw new HarnessError( + 'cross-session search is unavailable because the caller session has no workspace', + 'SESSION_QUERY_TOOL_UNAUTHORIZED', + ) + } + const query = toolInput.normalizeQuery(args.query) + const sessionFilters = toolInput.buildSessionFilters(args) + const eventFilters = toolInput.buildEventFilters({ + seqFrom: args.event_seq_from, + seqTo: args.event_seq_to, + timeFrom: args.event_time_from, + timeTo: args.event_time_to, + eventTypes: args.event_types, + surfaces: args.event_surfaces, + }) + const requestedParentIds = toolInput.materializeParentSessionIds(args.parent_session_ids) + if (requestedParentIds !== undefined || args.include_root_sessions === true) { + const authorizedParentIds = requestedParentIds === undefined + ? new Set() + : await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal) + const parentValues: Array = requestedParentIds + ?.filter(id => authorizedParentIds.has(id)) ?? [] + if (args.include_root_sessions === true) parentValues.push(null) + if (parentValues.length === 0) return presentation.formatEmptySessionSearch() + sessionFilters.push({ kind: 'parent', values: parentValues }) + } + sessionFilters.push({ kind: 'cwd', values: [cwd] }) + const collected = await collectPages( + maxResults, + exec.signal, + cursor => serviceBoundary.call(ctx, exec.signal, 'session search', () => + ctx.sessionQuery.searchSessions({ + query, + sessionFilters, + eventFilters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal })), + hit => hit.header.id !== caller.id && workspaceAccess.recordAuthorized(hit, caller), + ) + + const parentIds = collected.items + .map(hit => hit.header.parentSession) + .filter((id): id is SessionId => id !== undefined) + const authorizedParents = await workspaceAccess.authorizeSessionIds(ctx, caller, parentIds, exec.signal) + const titles = await workspaceAccess.readTitles( + ctx, + caller, + collected.items.map(hit => hit.header.id), + exec.signal, + ) + return presentation.formatSessionSearch(collected, titles, authorizedParents) +} + +async function executeEventSearch( + ctx: Context, + args: EventSearchArgs, + exec: ToolRunContext, + maxResults: number, +): Promise { + const caller = workspaceAccess.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const query = toolInput.normalizeQuery(args.query) + const range = toolInput.sequenceRange(args.seq_from, args.seq_to) + if (sessionId === caller.id) { + const stepStart = caller.events.findLast(event => event.type === 'step/start') + if (stepStart === undefined) { + throw new HarnessError( + 'current-session search requires an active step boundary', + 'SESSION_QUERY_TOOL_NO_CURRENT_STEP', + ) + } + range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, stepStart.seq - 1) + } + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + if (range.from !== undefined && range.to !== undefined && range.from > range.to) { + return presentation.formatEventSearch(sessionId, title, { items: [], capped: false }) + } + const filters = toolInput.buildEventFilters({ + seqFrom: range.from, + seqTo: range.to, + timeFrom: args.time_from, + timeTo: args.time_to, + eventTypes: args.event_types, + surfaces: args.surfaces, + }) + const collected = await collectPages( + maxResults, + exec.signal, + async (cursor): Promise => { + const page = await serviceBoundary.call(ctx, exec.signal, 'event search', () => + ctx.sessionQuery.searchEvents({ + sessionId, + query, + filters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal })) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, page.session) + return page + }, + () => true, + ) + return presentation.formatEventSearch(sessionId, title, collected) +} + +async function executeSessionTrace( + ctx: Context, + args: SessionTargetArgs, + exec: ToolRunContext, +): Promise { + const caller = workspaceAccess.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () => + ctx.sessionQuery.traceSession(sessionId, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.target.header) + + const ancestors: SessionRecord[] = [] + let ancestorBoundary = false + for (const ancestor of trace.ancestors) { + if (!workspaceAccess.recordAuthorized(ancestor, caller)) { + ancestorBoundary = true + break + } + ancestors.push(ancestor) + } + if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true + const descendants = workspaceAccess.authorizeDescendants(trace.descendants, caller) + const visibleIds = [ + trace.target.header.id, + ...ancestors.map(record => record.header.id), + ...workspaceAccess.descendantIds(descendants), + ] + const titles = await workspaceAccess.readTitles(ctx, caller, visibleIds, exec.signal) + return presentation.formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) +} + +async function executeEventTrace( + ctx: Context, + args: EventTargetArgs, + exec: ToolRunContext, +): Promise { + toolInput.assertNonNegativeSafeInteger('seq', args.seq) + const caller = workspaceAccess.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () => + ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session) + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + return presentation.formatEventTrace(sessionId, title, trace) +} + +async function executeEventRead( + ctx: Context, + args: EventReadArgs, + exec: ToolRunContext, +): Promise { + toolInput.assertNonNegativeSafeInteger('seq', args.seq) + if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) + if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) + const caller = workspaceAccess.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () => + ctx.sessionQuery.readEvent({ + sessionId, + seq: args.seq, + ...args.before === undefined ? {} : { before: args.before }, + ...args.after === undefined ? {} : { after: args.after }, + }, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, window.session) + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + return presentation.formatEventRead(sessionId, title, window) +} + +async function collectPages( + maxResults: number, + signal: AbortSignal, + request: (cursor?: SessionSearchCursor) => Promise<{ + readonly items: readonly T[] + readonly nextCursor?: SessionSearchCursor + }>, + accept: (item: T) => boolean, +): Promise> { + const items: T[] = [] + const seen = new Set() + let cursor: SessionSearchCursor | undefined + while (true) { + signal.throwIfAborted() + const page = await request(cursor) + signal.throwIfAborted() + for (const item of page.items) { + if (!accept(item)) continue + if (items.length === maxResults) { + return { items, capped: true } + } + items.push(item) + } + if (page.nextCursor === undefined) return { items, capped: false } + if (seen.has(page.nextCursor)) { + throw new SessionQueryError( + 'session-search provider repeated a continuation cursor', + 'SESSION_QUERY_INVALID_CURSOR', + ) + } + seen.add(page.nextCursor) + cursor = page.nextCursor + } +} + +/** Five model-facing session-query operation implementations. */ +export const operations = { + executeSessionSearch, + executeEventSearch, + executeSessionTrace, + executeEventTrace, + executeEventRead, +} diff --git a/packages/session-query/tool-session-query/src/presentation.ts b/packages/session-query/tool-session-query/src/presentation.ts new file mode 100644 index 0000000000..6e99bd22eb --- /dev/null +++ b/packages/session-query/tool-session-query/src/presentation.ts @@ -0,0 +1,255 @@ +/** + * Model text rendering and generic tool-call presentation. + * + * @module @deepseek-ai/dsh-tool-session-query/presentation + */ + +import { + extractSessionEventText, + type SessionEventSearchHit, + type SessionEventTraceObservation, + type SessionEventWindow, + type SessionLineageTrace, + type SessionRecord, + type SessionSearchHit, +} from '@deepseek-ai/dsh-session-query' +import type { + SessionEvent, + SessionId, +} from '@deepseek-ai/dsh-session' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import { workspaceAccess } from './workspace-access.ts' + +type TitleView = Awaited> +type CompleteTitleMap = Awaited> +type AuthorizedDescendants = ReturnType + +interface SearchCollection { + readonly items: T[] + readonly capped: boolean +} + +interface SessionSearchCallArgs { + readonly query: string +} + +interface EventSearchCallArgs { + readonly query: string +} + +interface SessionTargetCallArgs { + readonly session_id?: string +} + +interface EventTargetCallArgs extends SessionTargetCallArgs { + readonly seq: number +} + +function formatSessionSearch( + collected: SearchCollection, + titles: CompleteTitleMap, + authorizedParents: ReadonlySet, +): string { + if (collected.items.length === 0) return formatEmptySessionSearch() + const lines = [`Session search results (${collected.items.length}):`] + for (const [index, hit] of collected.items.entries()) { + const parent = hit.header.parentSession === undefined + ? 'root' + : authorizedParents.has(hit.header.parentSession) + ? hit.header.parentSession + : '[outside workspace]' + const availability = [ + hit.live ? 'live' : undefined, + hit.persisted ? 'persisted' : undefined, + ].filter((value): value is string => value !== undefined).join(', ') || 'unavailable' + lines.push( + '', + `${index + 1}. Session ${hit.header.id} — ${workspaceAccess.titleText(titles.get(hit.header.id))}`, + ` Created: ${formatTime(hit.header.createdAt)}`, + ` Parent: ${parent}`, + ` Availability: ${availability}`, + ` Best match: seq ${hit.bestMatch.seq} | ${hit.bestMatch.type} | ${hit.bestMatch.surface} | ${formatTime(hit.bestMatch.time)}`, + ` Snippet: ${hit.bestMatch.snippet}`, + ) + } + if (collected.capped) { + lines.push('', 'Result cap reached. Narrow the query or add filters to find additional matches.') + } + return lines.join('\n') +} + +function formatEmptySessionSearch(): string { + return 'No prior session matches found.' +} + +function formatEventSearch( + sessionId: SessionId, + title: TitleView, + collected: SearchCollection, +): string { + const lines = [`Session ${sessionId} — ${workspaceAccess.titleText(title)}`] + if (collected.items.length === 0) { + lines.push('', 'No prior event matches found.') + return lines.join('\n') + } + lines.push('', `Event search results (${collected.items.length}):`) + for (const [index, hit] of collected.items.entries()) { + lines.push( + `${index + 1}. seq ${hit.seq} | ${hit.type} | ${hit.surface} | ${formatTime(hit.time)}`, + ` Snippet: ${hit.snippet}`, + ) + } + if (collected.capped) { + lines.push('', 'Result cap reached. Narrow the query or add filters to find additional matches.') + } + return lines.join('\n') +} + +function formatSessionTrace( + trace: SessionLineageTrace, + ancestors: readonly SessionRecord[], + ancestorBoundary: boolean, + descendants: AuthorizedDescendants, + titles: CompleteTitleMap, +): string { + const lines = [ + `Session ${trace.target.header.id} — ${workspaceAccess.titleText(titles.get(trace.target.header.id))}`, + `Created: ${formatTime(trace.target.header.createdAt)}`, + `Availability: ${availabilityText(trace.target)}`, + '', + 'Ancestors (nearest first):', + ] + if (ancestors.length === 0 && !ancestorBoundary) lines.push('- none (target is a root session)') + for (const record of ancestors) { + lines.push(`- ${record.header.id} — ${workspaceAccess.titleText(titles.get(record.header.id))} | ${formatTime(record.header.createdAt)} | ${availabilityText(record)}`) + } + if (ancestorBoundary) lines.push('- [outside workspace boundary]') + lines.push('', 'Descendants:') + if (descendants.length === 0) lines.push('- none') + else renderDescendants(lines, descendants, titles) + return lines.join('\n') +} + +function renderDescendants( + lines: string[], + nodes: AuthorizedDescendants, + titles: CompleteTitleMap, +): void { + for (const { node, depth } of workspaceAccess.visitDescendants(nodes)) { + const indent = ' '.repeat(depth) + if (node === null) { + lines.push(`${indent}- [outside workspace subtree]`) + continue + } + const id = node.record.header.id + lines.push(`${indent}- ${id} — ${workspaceAccess.titleText(titles.get(id))} | ${formatTime(node.record.header.createdAt)} | ${availabilityText(node.record)}`) + } +} + +function formatEventTrace( + sessionId: SessionId, + title: TitleView, + trace: SessionEventTraceObservation, +): string { + return [ + `Session ${sessionId} — ${workspaceAccess.titleText(title)}`, + `Target: seq ${trace.target.seq} | ${trace.target.type} | ${trace.target.surface} | ${formatTime(trace.target.time)}`, + `Replaced by: ${trace.replacedBy ?? 'none'}`, + `Replacement chain: ${seqList(trace.replacementChain)}`, + `Events replaced by target: ${seqList(trace.replacedEventSeqs)}`, + `Direct provenance sources: ${seqList(trace.sourceEventSeqs)}`, + `Direct derived events: ${seqList(trace.derivedEventSeqs)}`, + ].join('\n') +} + +function formatEventRead( + sessionId: SessionId, + title: TitleView, + window: SessionEventWindow, +): string { + const before = window.events.filter(event => event.seq < window.target.seq) + const after = window.events.filter(event => event.seq > window.target.seq) + const lines = [ + `Session ${sessionId} — ${workspaceAccess.titleText(title)}`, + `Target event seq ${window.target.seq}:`, + '```json', + JSON.stringify(window.target, null, 2), + '```', + ] + if (before.length > 0) { + lines.push('', 'Before:') + for (const event of before) lines.push(formatNeighbor(event)) + } + if (after.length > 0) { + lines.push('', 'After:') + for (const event of after) lines.push(formatNeighbor(event)) + } + return lines.join('\n') +} + +function formatNeighbor(event: SessionEvent): string { + const text = extractSessionEventText(event) + return `- seq ${event.seq} | ${event.type} | ${formatTime(event.time)}` + + (text.length === 0 ? ' | (no semantic text)' : `\n ${text.replaceAll('\n', '\n ')}`) +} + +function availabilityText(record: SessionRecord): string { + return [ + record.live ? 'live' : undefined, + record.persisted ? 'persisted' : undefined, + ].filter((value): value is string => value !== undefined).join(', ') || 'unavailable' +} + +function seqList(values: readonly number[]): string { + return values.length === 0 ? 'none' : values.join(', ') +} + +function formatTime(value: number): string { + return new Date(value).toISOString() +} + +function presentSessionSearchCall(args: SessionSearchCallArgs): GenericCallView { + return { card: 'generic', kind: 'search', title: 'Search prior sessions', rawInput: args.query } +} + +function presentEventSearchCall(args: EventSearchCallArgs): GenericCallView { + return { card: 'generic', kind: 'search', title: 'Search session events', rawInput: args.query } +} + +function presentSessionTraceCall(args: SessionTargetCallArgs): GenericCallView { + return { + card: 'generic', + kind: 'read', + title: args.session_id === undefined ? 'Trace current session' : `Trace session ${args.session_id}`, + ...args.session_id === undefined ? {} : { rawInput: args.session_id }, + } +} + +function presentEventTargetCall( + action: string, + args: EventTargetCallArgs, +): GenericCallView { + return { + card: 'generic', + kind: 'read', + title: `${action} ${args.seq}`, + rawInput: { + ...args.session_id === undefined ? {} : { session_id: args.session_id }, + seq: args.seq, + }, + } +} + +/** Text output and call-card presentation for every session-query tool. */ +export const presentation = { + formatSessionSearch, + formatEmptySessionSearch, + formatEventSearch, + formatSessionTrace, + formatEventTrace, + formatEventRead, + presentSessionSearchCall, + presentEventSearchCall, + presentSessionTraceCall, + presentEventTargetCall, +} diff --git a/packages/session-query/tool-session-query/src/service-boundary.ts b/packages/session-query/tool-session-query/src/service-boundary.ts new file mode 100644 index 0000000000..bf1dbd24f4 --- /dev/null +++ b/packages/session-query/tool-session-query/src/service-boundary.ts @@ -0,0 +1,171 @@ +/** + * Session-query service error containment and model-safe translation. + * + * @module @deepseek-ai/dsh-tool-session-query/service-boundary + */ + +import type { Context } from 'cordis' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { + SessionQueryError, + type SessionQueryErrorCode, +} from '@deepseek-ai/dsh-session-query' + +interface ModelSafeServiceFailure { + readonly code: SessionQueryErrorCode | 'SESSION_QUERY_TOOL_FAILED' + readonly message: string +} + +const UNPRINTABLE_SERVICE_ERROR = '[unprintable session query failure]' + +const SAFE_SESSION_QUERY_FAILURES = { + SESSION_QUERY_ABORTED: { + code: 'SESSION_QUERY_ABORTED', + message: 'session query was cancelled', + }, + SESSION_QUERY_EVENT_NOT_FOUND: { + code: 'SESSION_QUERY_EVENT_NOT_FOUND', + message: 'session event was not found', + }, + SESSION_QUERY_INDEX_FAILED: { + code: 'SESSION_QUERY_INDEX_FAILED', + message: 'session search index is unavailable', + }, + SESSION_QUERY_INVALID_CONFIG: { + code: 'SESSION_QUERY_TOOL_FAILED', + message: 'session query operation failed', + }, + SESSION_QUERY_INVALID_CURSOR: { + code: 'SESSION_QUERY_INVALID_CURSOR', + message: 'session search continuation is invalid', + }, + SESSION_QUERY_INVALID_FILTER: { + code: 'SESSION_QUERY_INVALID_FILTER', + message: 'session query filters were rejected', + }, + SESSION_QUERY_INVALID_LIMIT: { + code: 'SESSION_QUERY_INVALID_LIMIT', + message: 'session query result limit was rejected', + }, + SESSION_QUERY_INVALID_QUERY: { + code: 'SESSION_QUERY_INVALID_QUERY', + message: 'session query was rejected', + }, + SESSION_QUERY_INVALID_LINEAGE: { + code: 'SESSION_QUERY_INVALID_LINEAGE', + message: 'session lineage is invalid', + }, + SESSION_QUERY_INVALID_SURFACE: { + code: 'SESSION_QUERY_INVALID_SURFACE', + message: 'session event history is invalid', + }, + SESSION_QUERY_INVALID_WINDOW: { + code: 'SESSION_QUERY_INVALID_WINDOW', + message: 'session event window is invalid', + }, + SESSION_QUERY_PERSISTENCE_FAILED: { + code: 'SESSION_QUERY_PERSISTENCE_FAILED', + message: 'session history storage is unavailable', + }, + SESSION_QUERY_SESSION_NOT_FOUND: { + code: 'SESSION_QUERY_SESSION_NOT_FOUND', + message: 'session was not found', + }, + SESSION_QUERY_STALE_CURSOR: { + code: 'SESSION_QUERY_STALE_CURSOR', + message: 'session history changed while paging; retry the complete search call', + }, + SESSION_QUERY_SOURCE_CONFLICT: { + code: 'SESSION_QUERY_TOOL_FAILED', + message: 'session query operation failed', + }, +} satisfies Record + +function unauthorizedTarget(): HarnessError { + return new HarnessError( + 'session target is outside the caller workspace', + 'SESSION_QUERY_TOOL_UNAUTHORIZED', + ) +} + +async function call( + ctx: Context, + signal: AbortSignal, + operation: string, + invoke: () => Promise, +): Promise { + signal.throwIfAborted() + try { + const value = await invoke() + signal.throwIfAborted() + return value + } catch (error: unknown) { + signal.throwIfAborted() + throw sanitizeError(ctx, operation, error) + } +} + +function sanitizeError( + ctx: Context, + operation: string, + error: unknown, +): HarnessError { + const generic = genericFailure() + const diagnostic = fullError(error) + try { + ctx.logger.warn(`tool-session-query: ${operation} failed: ${diagnostic}`) + if (error instanceof SessionQueryError) { + const code: unknown = error.code + const failure = typeof code === 'string' && Object.hasOwn(SAFE_SESSION_QUERY_FAILURES, code) + ? SAFE_SESSION_QUERY_FAILURES[code as SessionQueryErrorCode] + : undefined + if (failure !== undefined && failure.code !== 'SESSION_QUERY_TOOL_FAILED') { + return new SessionQueryError(failure.message, failure.code) + } + } + if (error instanceof HarnessError && error.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') { + return unauthorizedTarget() + } + } catch { + return generic + } + return generic +} + +function genericFailure(): HarnessError { + return new HarnessError( + 'session query operation failed', + 'SESSION_QUERY_TOOL_FAILED', + ) +} + +function fullError(error: unknown): string { + try { + return renderFullError(error) + } catch { + return UNPRINTABLE_SERVICE_ERROR + } +} + +function renderFullError(error: unknown): string { + if (!(error instanceof Error)) return String(error) + const diagnostics: string[] = [] + const seen = new Set() + let current: unknown = error + while (current instanceof Error && !seen.has(current)) { + seen.add(current) + diagnostics.push(current.stack ?? String(current)) + current = current.cause + } + /* v8 ignore next -- defensive containment for a cyclic Error.cause graph */ + if (current instanceof Error) diagnostics.push('[circular error cause]') + else if (current !== undefined) diagnostics.push(renderFullError(current)) + return diagnostics.join('\nCaused by: ') +} + +/** Model-safe session-query invocation and error translation boundary. */ +export const serviceBoundary = { + unauthorizedTarget, + call, + sanitizeError, +} diff --git a/packages/session-query/tool-session-query/src/workspace-access.ts b/packages/session-query/tool-session-query/src/workspace-access.ts new file mode 100644 index 0000000000..faba3adf9f --- /dev/null +++ b/packages/session-query/tool-session-query/src/workspace-access.ts @@ -0,0 +1,255 @@ +/** + * Caller identity, workspace authorization, and visible lineage projection. + * + * @module @deepseek-ai/dsh-tool-session-query/workspace-access + */ + +import type { Context } from 'cordis' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { + SessionId, + type SessionEvent, + type SessionHeader, + type SessionId as SessionIdValue, +} from '@deepseek-ai/dsh-session' +import type { + SessionLineageNode, + SessionRecord, +} from '@deepseek-ai/dsh-session-query' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import { serviceBoundary } from './service-boundary.ts' + +interface Caller { + readonly id: SessionIdValue + readonly header: SessionHeader + readonly events: readonly SessionEvent[] +} + +interface TitleView { + readonly text: string + readonly unavailableCode?: string +} + +interface CompleteTitleMap extends ReadonlyMap { + get(id: SessionIdValue): TitleView +} + +interface AuthorizedDescendant { + readonly record: SessionRecord + readonly descendants: Array +} + +interface DescendantProjectionFrame { + readonly node: SessionLineageNode + readonly target: Array + readonly next: DescendantProjectionFrame | undefined +} + +interface DescendantVisit { + readonly node: AuthorizedDescendant | null + readonly depth: number + readonly next: DescendantVisit | undefined +} + +function callerOf(exec: ToolRunContext): Caller { + const agent = exec.agent + if (agent === undefined) { + throw new HarnessError( + 'session query tools require an agent-bound caller', + 'SESSION_QUERY_TOOL_MISSING_AGENT', + ) + } + return { + id: agent.session.id, + header: agent.session.header, + events: agent.session.events, + } +} + +function targetId(args: { readonly session_id?: string }, caller: Caller): SessionIdValue { + return args.session_id === undefined ? caller.id : SessionId(args.session_id) +} + +async function authorizeTarget( + ctx: Context, + caller: Caller, + target: SessionIdValue, + signal: AbortSignal, +): Promise { + if (target === caller.id) return + const cwd = caller.header.cwd + if (cwd === undefined) throw serviceBoundary.unauthorizedTarget() + const records = await serviceBoundary.call(ctx, signal, 'target authorization', () => + ctx.sessionQuery.filterSessions([ + { kind: 'id', values: [target] }, + { kind: 'cwd', values: [cwd] }, + ], signal)) + if (records.length !== 1) throw serviceBoundary.unauthorizedTarget() +} + +function recordAuthorized(record: SessionRecord, caller: Caller): boolean { + return headerAuthorized(record.header, caller) +} + +function headerAuthorized(header: SessionHeader, caller: Caller): boolean { + if (header.id === caller.id) return header.cwd === caller.header.cwd + return caller.header.cwd !== undefined && header.cwd === caller.header.cwd +} + +function assertObservedTargetAuthorized( + caller: Caller, + target: SessionIdValue, + observed: SessionHeader, +): void { + if (observed.id !== target || !headerAuthorized(observed, caller)) { + throw serviceBoundary.unauthorizedTarget() + } +} + +async function authorizeSessionIds( + ctx: Context, + caller: Caller, + ids: readonly SessionIdValue[], + signal: AbortSignal, +): Promise> { + const unique = [...new Set(ids)] + const authorized = new Set() + if (unique.includes(caller.id)) authorized.add(caller.id) + const cwd = caller.header.cwd + const other = unique.filter(id => id !== caller.id) + if (cwd === undefined || other.length === 0) return authorized + const records = await serviceBoundary.call(ctx, signal, 'session-id authorization', () => + ctx.sessionQuery.filterSessions([ + { kind: 'id', values: other }, + { kind: 'cwd', values: [cwd] }, + ], signal)) + const requested = new Set(other) + for (const record of records) { + if (requested.has(record.header.id) && recordAuthorized(record, caller)) { + authorized.add(record.header.id) + } + } + return authorized +} + +async function readTitles( + ctx: Context, + caller: Caller, + ids: readonly SessionIdValue[], + signal: AbortSignal, +): Promise { + const result = new Map() + const observations = await serviceBoundary.call(ctx, signal, 'title observation', () => + ctx.sessionQuery.readTitleSnapshots(ids, signal)) + for (const observation of observations) { + if (observation.status === 'rejected') { + result.set(observation.sessionId, unavailableTitle(ctx, observation.reason)) + continue + } + assertObservedTargetAuthorized(caller, observation.sessionId, observation.value.session) + result.set(observation.sessionId, { text: observation.value.title?.title ?? 'untitled' }) + } + return result as CompleteTitleMap +} + +async function readTitle( + ctx: Context, + caller: Caller, + id: SessionIdValue, + signal: AbortSignal, +): Promise { + return (await readTitles(ctx, caller, [id], signal)).get(id) +} + +function unavailableTitle( + ctx: Context, + error: unknown, +): TitleView { + const sanitized = serviceBoundary.sanitizeError(ctx, 'title observation item', error) + if (sanitized.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw sanitized + return { text: 'untitled', unavailableCode: sanitized.code } +} + +function authorizeDescendants( + nodes: readonly SessionLineageNode[], + caller: Caller, +): Array { + const result: Array = [] + let pending: DescendantProjectionFrame | undefined + for (const node of [...nodes].reverse()) { + pending = { node, target: result, next: pending } + } + while (pending !== undefined) { + const current = pending + pending = current.next + if (!recordAuthorized(current.node.session, caller)) { + current.target.push(null) + continue + } + const projected: AuthorizedDescendant = { + record: current.node.session, + descendants: [], + } + current.target.push(projected) + for (const child of [...current.node.descendants].reverse()) { + pending = { + node: child, + target: projected.descendants, + next: pending, + } + } + } + return result +} + +function * visitDescendants( + nodes: readonly (AuthorizedDescendant | null)[], +): Generator { + let pending: DescendantVisit | undefined + for (const node of [...nodes].reverse()) { + pending = { node, depth: 0, next: pending } + } + while (pending !== undefined) { + const current = pending + pending = current.next + yield current + if (current.node === null) continue + for (const child of [...current.node.descendants].reverse()) { + pending = { + node: child, + depth: current.depth + 1, + next: pending, + } + } + } +} + +function descendantIds(nodes: readonly (AuthorizedDescendant | null)[]): SessionIdValue[] { + const ids: SessionIdValue[] = [] + for (const { node } of visitDescendants(nodes)) { + if (node !== null) ids.push(node.record.header.id) + } + return ids +} + +function titleText(view: TitleView): string { + return view.unavailableCode === undefined + ? view.text + : `${view.text} (title unavailable: ${view.unavailableCode})` +} + +/** Workspace-scoped caller authorization, title access, and lineage projection. */ +export const workspaceAccess = { + callerOf, + targetId, + authorizeTarget, + recordAuthorized, + assertObservedTargetAuthorized, + authorizeSessionIds, + readTitles, + readTitle, + authorizeDescendants, + visitDescendants, + descendantIds, + titleText, +}