From 5b401b27a5a7ed2596e6294bbd64603d4d57669d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 13:08:18 +0800 Subject: [PATCH] refactor(tool-session-query): pass projection handles to operations --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../tool-session-query/src/index.ts | 16 +- .../tool-session-query/src/operations.ts | 420 ++++++++---------- .../src/workspace-access.ts | 19 + 5 files changed, 215 insertions(+), 244 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index eb2a6a1ea2..8460cc7395 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3921,7 +3921,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` - `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` -- `source`: [`packages/experimental/tool-session-query/src/index.ts:26`](../packages/experimental/tool-session-query/src/index.ts) +- `source`: [`packages/experimental/tool-session-query/src/index.ts:27`](../packages/experimental/tool-session-query/src/index.ts) ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index aacdae8e58..ffd0313631 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3923,7 +3923,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` - `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` -- `source`: [`packages/experimental/tool-session-query/src/index.ts:26`](../packages/experimental/tool-session-query/src/index.ts) +- `source`: [`packages/experimental/tool-session-query/src/index.ts:27`](../packages/experimental/tool-session-query/src/index.ts) ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ diff --git a/packages/experimental/tool-session-query/src/index.ts b/packages/experimental/tool-session-query/src/index.ts index 8bbf6c3bff..2337b43402 100644 --- a/packages/experimental/tool-session-query/src/index.ts +++ b/packages/experimental/tool-session-query/src/index.ts @@ -5,11 +5,12 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' import { toolInput } from './input.ts' -import { SessionQueryOperations } from './operations.ts' +import { operations } from './operations.ts' import { presentation } from './presentation.ts' export const name = 'tool-session-query' @@ -58,8 +59,7 @@ const PROMPT_TEXT = * @returns when the tools and their projection reader are ready. */ export async function apply(ctx: Context, config: Config): Promise { - const operations = new SessionQueryOperations(ctx) - await operations.initialize() + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) const resolved = resolveConfig(config) ctx.systemPrompt.section({ name: 'tool:session-query', @@ -73,7 +73,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: toolInput.sessionSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => operations.executeSessionSearch(args, exec, resolved.maxSearchResults), + execute: (args, exec) => operations.executeSessionSearch(ctx, turnBoundary, args, exec, resolved.maxSearchResults), presentCall: presentation.presentSessionSearchCall, })) @@ -83,7 +83,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: toolInput.eventSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => operations.executeEventSearch(args, exec, resolved.maxSearchResults), + execute: (args, exec) => operations.executeEventSearch(ctx, turnBoundary, args, exec, resolved.maxSearchResults), presentCall: presentation.presentEventSearchCall, })) @@ -93,7 +93,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: toolInput.targetSessionParameter, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeSessionTrace(args, exec), + execute: (args, exec) => operations.executeSessionTrace(ctx, turnBoundary, args, exec), presentCall: presentation.presentSessionTraceCall, })) @@ -106,7 +106,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeEventTrace(args, exec), + execute: (args, exec) => operations.executeEventTrace(ctx, turnBoundary, args, exec), presentCall: args => presentation.presentEventTargetCall('Trace event', args), })) @@ -121,7 +121,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeEventRead(args, exec), + execute: (args, exec) => operations.executeEventRead(ctx, turnBoundary, args, exec), presentCall: args => presentation.presentEventTargetCall('Read event', args), })) } diff --git a/packages/experimental/tool-session-query/src/operations.ts b/packages/experimental/tool-session-query/src/operations.ts index 4c05480221..2727447537 100644 --- a/packages/experimental/tool-session-query/src/operations.ts +++ b/packages/experimental/tool-session-query/src/operations.ts @@ -4,9 +4,9 @@ * @module @deepseek-ai/dsh-tool-session-query/operations */ -import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' -import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' +import type { TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { HarnessError } from '@deepseek-ai/dsh-llm' import { SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -54,249 +54,192 @@ interface SearchCollection { readonly capped: boolean } -/** Session-history tool operations over the caller’s current turn state. */ -export class SessionQueryOperations { - private turnBoundary!: ProjectionHandle - - constructor(private readonly ctx: Context) {} - - /** - * Acquire projection readers before exposing dependent operations. - * @returns when all required registrations are ready. - */ - async initialize(): Promise { - this.turnBoundary = await this.ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +async function executeSessionSearch( + ctx: Context, + turnBoundary: ProjectionHandle, + args: SessionSearchArgs, + exec: ToolRunContext, + maxResults: number, +): Promise { + const caller = workspaceAccess.callerOf(exec, turnBoundary) + 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', + ) } - - private callerOf(exec: ToolRunContext) { - 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, - boundary: this.turnBoundary.stateOf(agent.session), - } + 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), + ) - /** - * Search other Sessions within the caller’s authorized workspace. - * @param args - Session and event filters. - * @param exec - Agent-bound caller and cancellation signal. - * @param maxResults - Maximum number of authorized matches to return. - * @returns Formatted authorized Session matches and truncation status. - */ - async executeSessionSearch( - args: SessionSearchArgs, - exec: ToolRunContext, - maxResults: number, - ): Promise { - const ctx = this.ctx - const caller = this.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({ + 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, + turnBoundary: ProjectionHandle, + args: EventSearchArgs, + exec: ToolRunContext, + maxResults: number, +): Promise { + const caller = workspaceAccess.callerOf(exec, turnBoundary) + 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 stepStartSeq = caller.boundary.lastStepStartSeq + // `null` (no step started yet) caps the range to a degenerate `to` the + // filter validation rejects — the loop never runs tools outside a step. + range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 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, - sessionFilters, - eventFilters, + filters, ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal })), - hit => hit.header.id !== caller.id && workspaceAccess.recordAuthorized(hit, caller), - ) + }, { signal: exec.signal })) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, page.session) + return page + }, + () => true, + ) + return presentation.formatEventSearch(sessionId, title, collected) +} - 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 executeSessionTrace( + ctx: Context, + turnBoundary: ProjectionHandle, + args: SessionTargetArgs, + exec: ToolRunContext, +): Promise { + const caller = workspaceAccess.callerOf(exec, turnBoundary) + 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) - /** - * Search authorized Session events, excluding the caller’s current step. - * @param args - Target Session and event filters. - * @param exec - Agent-bound caller and cancellation signal. - * @param maxResults - Maximum number of authorized matches to return. - * @returns Formatted matching events and truncation status. - */ - async executeEventSearch( - args: EventSearchArgs, - exec: ToolRunContext, - maxResults: number, - ): Promise { - const ctx = this.ctx - const caller = this.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 stepStartSeq = caller.boundary.lastStepStartSeq - // `null` (no step started yet) caps the range to a degenerate `to` the - // filter validation rejects — the loop never runs tools outside a step. - range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 1) + const ancestors: SessionRecord[] = [] + let ancestorBoundary = false + for (const ancestor of trace.ancestors) { + if (!workspaceAccess.recordAuthorized(ancestor, caller)) { + ancestorBoundary = true + break } - 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) + 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) +} - /** - * Trace Session lineage within the caller’s workspace permissions. - * @param args - Optional target Session id; defaults to the caller. - * @param exec - Agent-bound caller and cancellation signal. - * @returns Formatted authorized ancestors and descendants. - */ - async executeSessionTrace( - args: SessionTargetArgs, - exec: ToolRunContext, - ): Promise { - const ctx = this.ctx - const caller = this.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) +async function executeEventTrace( + ctx: Context, + turnBoundary: ProjectionHandle, + args: EventTargetArgs, + exec: ToolRunContext, +): Promise { + toolInput.assertNonNegativeSafeInteger('seq', args.seq) + const seq = SessionSeq(args.seq) + const caller = workspaceAccess.callerOf(exec, turnBoundary) + 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 }, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session) + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + return presentation.formatEventTrace(sessionId, title, trace) +} - 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) - } - - /** - * Trace an event after authorizing its owning Session. - * @param args - Target Session and event sequence. - * @param exec - Agent-bound caller and cancellation signal. - * @returns Formatted event lineage. - */ - async executeEventTrace( - args: EventTargetArgs, - exec: ToolRunContext, - ): Promise { - const ctx = this.ctx - toolInput.assertNonNegativeSafeInteger('seq', args.seq) - const seq = SessionSeq(args.seq) - const caller = this.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 }, exec.signal)) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session) - const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) - return presentation.formatEventTrace(sessionId, title, trace) - } - - /** - * Read an event window after authorizing its owning Session. - * @param args - Target event and surrounding event counts. - * @param exec - Agent-bound caller and cancellation signal. - * @returns Formatted event window. - */ - async executeEventRead( - args: EventReadArgs, - exec: ToolRunContext, - ): Promise { - const ctx = this.ctx - toolInput.assertNonNegativeSafeInteger('seq', args.seq) - const seq = SessionSeq(args.seq) - if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) - if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) - const caller = this.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.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 executeEventRead( + ctx: Context, + turnBoundary: ProjectionHandle, + args: EventReadArgs, + exec: ToolRunContext, +): Promise { + toolInput.assertNonNegativeSafeInteger('seq', args.seq) + const seq = SessionSeq(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, turnBoundary) + 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.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( @@ -333,3 +276,12 @@ async function collectPages( cursor = page.nextCursor } } + +/** Five model-facing session-query operation implementations. */ +export const operations = { + executeSessionSearch, + executeEventSearch, + executeSessionTrace, + executeEventTrace, + executeEventRead, +} diff --git a/packages/experimental/tool-session-query/src/workspace-access.ts b/packages/experimental/tool-session-query/src/workspace-access.ts index 4f11e64f38..836763cb3e 100644 --- a/packages/experimental/tool-session-query/src/workspace-access.ts +++ b/packages/experimental/tool-session-query/src/workspace-access.ts @@ -6,6 +6,7 @@ import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' +import { HarnessError } from '@deepseek-ai/dsh-llm' import { type SessionHeader, type SessionId as SessionIdValue, @@ -15,6 +16,8 @@ import type { SessionLineageNode, SessionRecord, } from '@deepseek-ai/dsh-session-query' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { serviceBoundary } from './service-boundary.ts' interface Caller { @@ -50,6 +53,21 @@ interface DescendantVisit { readonly next: DescendantVisit | undefined } +function callerOf(exec: ToolRunContext, turnBoundary: ProjectionHandle): 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, + boundary: turnBoundary.stateOf(agent.session), + } +} + function targetId(args: { readonly session_id?: string }, caller: Caller): SessionIdValue { return args.session_id === undefined ? caller.id : brandString(args.session_id) } @@ -224,6 +242,7 @@ function titleText(view: TitleView): string { /** Workspace-scoped caller authorization, title access, and lineage projection. */ export const workspaceAccess = { + callerOf, targetId, authorizeTarget, recordAuthorized,