[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
788
infrastructure/ai/harness/capabilityTools.ts
Normal file
788
infrastructure/ai/harness/capabilityTools.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import type { ExecutorContext, NetcattyBridge } from '../cattyAgent/executor';
|
||||
import type { TerminalContextReadRange } from '../../../domain/terminalContextRead';
|
||||
import type { AIPermissionMode } from '../types';
|
||||
import type { WebSearchConfig } from '../types';
|
||||
import { isWebSearchReady } from '../types';
|
||||
import {
|
||||
executeTerminalExecute,
|
||||
executeWorkspaceGetInfo,
|
||||
executeWorkspaceGetSessionInfo,
|
||||
executeWebSearch,
|
||||
executeUrlFetch,
|
||||
type ToolDeps,
|
||||
type ToolExecResult,
|
||||
} from '../shared/toolExecutors';
|
||||
import { reserveSessionSlot } from '../shared/sessionExecutionQueue';
|
||||
import { fitTerminalExecuteResultForModel } from './terminalCompression';
|
||||
import { fitLargeToolResultForModel } from './toolResultFitting';
|
||||
import { redactSecretsForModel } from './modelSecretRedaction';
|
||||
import {
|
||||
globalTerminalMonitorGuard,
|
||||
isStreamingMonitorCommand,
|
||||
} from './terminalMonitorGuard';
|
||||
import type { ToolOutputStore } from './toolOutputStore';
|
||||
import { TOOL_OUTPUT_READ_MAX_CHARS } from './toolOutputStore';
|
||||
import {
|
||||
buildTerminalWriteFingerprint,
|
||||
hashScopeKey,
|
||||
hashToolResult,
|
||||
previewToolResult,
|
||||
type ToolResultDedup,
|
||||
} from './toolResultDedup';
|
||||
import cattyToolSpecs from './generated/cattyToolSpecs.json';
|
||||
import {
|
||||
cattyToolContextSchema,
|
||||
toolDepsFromContext,
|
||||
type CattyToolContext,
|
||||
} from './cattyRuntimeContext';
|
||||
|
||||
type FieldShape = {
|
||||
type: string;
|
||||
optional?: boolean;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type CattyToolSpec = {
|
||||
capabilityId: string;
|
||||
toolName: string;
|
||||
rpcMethod: string | null;
|
||||
localExecution?: boolean;
|
||||
description: string;
|
||||
inputShape: Record<string, FieldShape>;
|
||||
policy: {
|
||||
write: boolean;
|
||||
bypassesApproval: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type CattyToolsBundle = {
|
||||
tools: Record<string, ReturnType<typeof tool>>;
|
||||
toolsContext: Record<string, CattyToolContext>;
|
||||
};
|
||||
|
||||
function buildZodObject(shape: Record<string, FieldShape>): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
||||
const entries: Record<string, z.ZodTypeAny> = {};
|
||||
for (const [key, field] of Object.entries(shape)) {
|
||||
let schema: z.ZodTypeAny = field.type === 'number' ? z.number() : z.string();
|
||||
if (field.description) {
|
||||
schema = schema.describe(field.description);
|
||||
}
|
||||
entries[key] = field.optional ? schema.optional() : schema;
|
||||
}
|
||||
return z.object(entries);
|
||||
}
|
||||
|
||||
function unwrap<T>(r: ToolExecResult<T>): T | { error: string } {
|
||||
if (r.ok === false) return { error: r.error };
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async function invokeCapabilityRpc(
|
||||
bridge: NetcattyBridge,
|
||||
rpcMethod: string,
|
||||
params: Record<string, unknown>,
|
||||
chatSessionId?: string,
|
||||
): Promise<unknown> {
|
||||
if (!bridge.aiCapability) {
|
||||
return { error: 'Capability bridge is unavailable in this environment.' };
|
||||
}
|
||||
const result = await bridge.aiCapability(rpcMethod, params, chatSessionId);
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok: boolean }).ok === false) {
|
||||
return { error: (result as { error?: string }).error || 'Capability call failed.' };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function tryFetchHostEnvironment(
|
||||
bridge: NetcattyBridge,
|
||||
chatSessionId?: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
if (!bridge.aiCapability || !chatSessionId) return null;
|
||||
try {
|
||||
const environment = await invokeCapabilityRpc(
|
||||
bridge,
|
||||
'netcatty/getContext',
|
||||
{},
|
||||
chatSessionId,
|
||||
);
|
||||
if (environment && typeof environment === 'object' && !('error' in environment)) {
|
||||
return environment as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// IPC failures must not block read-only harness tools.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyToolDedup(
|
||||
toolName: string,
|
||||
fingerprint: string,
|
||||
result: unknown,
|
||||
dedup?: ToolResultDedup,
|
||||
): unknown {
|
||||
if (!dedup) return result;
|
||||
const cached = dedup.check(fingerprint);
|
||||
if (cached) {
|
||||
return dedup.buildCachedNotice(cached);
|
||||
}
|
||||
dedup.remember(toolName, fingerprint, previewToolResult(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
function fitCapabilityResultForModel(
|
||||
result: unknown,
|
||||
spec: CattyToolSpec,
|
||||
chatSessionId?: string,
|
||||
toolOutputStore?: ToolOutputStore,
|
||||
args?: Record<string, unknown>,
|
||||
toolResultDedup?: ToolResultDedup,
|
||||
): unknown {
|
||||
if (spec.capabilityId === 'harness.tool_output.read') {
|
||||
return result;
|
||||
}
|
||||
|
||||
const resultRecord = result && typeof result === 'object'
|
||||
? result as Record<string, unknown>
|
||||
: undefined;
|
||||
const jobId = typeof args?.jobId === 'string'
|
||||
? args.jobId
|
||||
: typeof resultRecord?.jobId === 'string' ? resultRecord.jobId : undefined;
|
||||
const terminalSessionId = typeof args?.sessionId === 'string'
|
||||
? args.sessionId
|
||||
: typeof resultRecord?.sessionId === 'string'
|
||||
? resultRecord.sessionId
|
||||
: jobId ? toolResultDedup?.terminalSessionForJob(jobId) : undefined;
|
||||
|
||||
return fitLargeToolResultForModel({
|
||||
result,
|
||||
capabilityId: spec.capabilityId,
|
||||
chatSessionId,
|
||||
toolOutputStore,
|
||||
terminalSessionId,
|
||||
normalizeStrings: spec.capabilityId.startsWith('terminal.')
|
||||
|| spec.capabilityId === 'harness.terminal.read_context',
|
||||
});
|
||||
}
|
||||
|
||||
export function applyMonitorStopResult(
|
||||
poll: Record<string, unknown>,
|
||||
stopResult: unknown,
|
||||
suppressedCount: number,
|
||||
): Record<string, unknown> {
|
||||
const stopFailed = Boolean(
|
||||
stopResult && typeof stopResult === 'object'
|
||||
&& (
|
||||
(stopResult as { ok?: boolean }).ok === false
|
||||
|| (
|
||||
typeof (stopResult as { error?: unknown }).error === 'string'
|
||||
&& (stopResult as { error: string }).error.trim().length > 0
|
||||
)
|
||||
),
|
||||
);
|
||||
return stopFailed
|
||||
? {
|
||||
...poll,
|
||||
output: `[automatic monitor stop failed after sustained overload; ${suppressedCount} batches were suppressed. The job may still be running; poll/stop it explicitly and narrow the command before continuing.]`,
|
||||
}
|
||||
: {
|
||||
...poll,
|
||||
status: 'stopping',
|
||||
output: `[monitor stop requested after sustained overload; ${suppressedCount} batches were suppressed. Narrow the command with grep/awk before restarting it.]`,
|
||||
};
|
||||
}
|
||||
|
||||
interface LocalExecutionContext {
|
||||
deps: ToolDeps;
|
||||
spec: CattyToolSpec;
|
||||
args: Record<string, unknown>;
|
||||
toolOutputStore?: ToolOutputStore;
|
||||
toolResultDedup?: ToolResultDedup;
|
||||
chatSessionId?: string;
|
||||
}
|
||||
|
||||
async function executeLocalCattyCapability(ctx: LocalExecutionContext): Promise<unknown> {
|
||||
const { deps, spec, args, toolOutputStore, toolResultDedup, chatSessionId } = ctx;
|
||||
const resolveContext = () => (typeof deps.context === 'function' ? deps.context() : deps.context);
|
||||
|
||||
switch (spec.capabilityId) {
|
||||
case 'harness.tool_output.read': {
|
||||
const { handleId, mode, maxChars, offset, query } = args as {
|
||||
handleId: string;
|
||||
mode?: 'head' | 'tail' | 'full' | 'range' | 'search';
|
||||
maxChars?: number;
|
||||
offset?: number;
|
||||
query?: string;
|
||||
};
|
||||
if (!toolOutputStore || !chatSessionId) {
|
||||
return { error: 'Tool output store is unavailable.' };
|
||||
}
|
||||
const requestedChars = Math.min(
|
||||
TOOL_OUTPUT_READ_MAX_CHARS,
|
||||
typeof maxChars === 'number' && Number.isFinite(maxChars)
|
||||
? Math.max(1, Math.floor(maxChars))
|
||||
: TOOL_OUTPUT_READ_MAX_CHARS,
|
||||
);
|
||||
const grantedChars = toolResultDedup
|
||||
? toolResultDedup.takeBudget('tool-output-read', requestedChars, TOOL_OUTPUT_READ_MAX_CHARS * 2)
|
||||
: requestedChars;
|
||||
if (grantedChars <= 0) {
|
||||
return {
|
||||
error: `This turn has reached its ${TOOL_OUTPUT_READ_MAX_CHARS * 2}-character saved-output read budget. Continue in the next turn or narrow the search.`,
|
||||
};
|
||||
}
|
||||
const result = await toolOutputStore.readChunkAsync(
|
||||
{ handleId, mode, maxChars: grantedChars, offset, query },
|
||||
chatSessionId,
|
||||
);
|
||||
if (result == null) {
|
||||
return { error: `Handle "${handleId}" was not found for this chat session.` };
|
||||
}
|
||||
return { ...result, content: redactSecretsForModel(result.content) };
|
||||
}
|
||||
case 'harness.workspace.get_info': {
|
||||
const scopeCtx = resolveContext();
|
||||
const fingerprint = toolResultDedup?.fingerprintFor(
|
||||
spec.toolName,
|
||||
hashScopeKey([chatSessionId, scopeCtx.workspaceId, String(scopeCtx.sessions?.length ?? 0)]),
|
||||
);
|
||||
const local = executeWorkspaceGetInfo(deps);
|
||||
if (local.ok === false) {
|
||||
return unwrap(local);
|
||||
}
|
||||
let merged: unknown = local.data;
|
||||
const environment = await tryFetchHostEnvironment(deps.bridge, chatSessionId);
|
||||
if (environment) {
|
||||
const hosts = Array.isArray(environment.hosts)
|
||||
? (environment.hosts as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const hostBySessionId = new Map(hosts.map((host) => [String(host.sessionId), host]));
|
||||
merged = {
|
||||
...local.data,
|
||||
sessions: local.data.sessions.map((session) => ({
|
||||
...session,
|
||||
...(hostBySessionId.get(session.sessionId) ?? {}),
|
||||
})),
|
||||
activePortForwardTunnels: environment.activePortForwardTunnels,
|
||||
};
|
||||
}
|
||||
if (fingerprint) {
|
||||
return applyToolDedup(spec.toolName, fingerprint, merged, toolResultDedup);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
case 'harness.workspace.get_session_info': {
|
||||
const { sessionId } = args as { sessionId: string };
|
||||
const local = executeWorkspaceGetSessionInfo(deps, { sessionId });
|
||||
if (local.ok === false) {
|
||||
return unwrap(local);
|
||||
}
|
||||
const environment = await tryFetchHostEnvironment(deps.bridge, chatSessionId);
|
||||
if (environment) {
|
||||
const hosts = Array.isArray(environment.hosts)
|
||||
? (environment.hosts as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const match = hosts.find((host) => String(host.sessionId) === sessionId);
|
||||
if (match) {
|
||||
return { ...local.data, ...match };
|
||||
}
|
||||
}
|
||||
return local.data;
|
||||
}
|
||||
case 'harness.terminal.read_context': {
|
||||
const scopeCtx = resolveContext();
|
||||
const sessions = scopeCtx.sessions ?? [];
|
||||
const requestedSessionId = typeof args.sessionId === 'string' && args.sessionId.trim()
|
||||
? args.sessionId.trim()
|
||||
: undefined;
|
||||
const sessionId = requestedSessionId
|
||||
?? (sessions.length === 1 ? sessions[0].sessionId : undefined);
|
||||
|
||||
if (!sessionId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'sessionId is required because the current AI scope contains multiple terminal sessions.',
|
||||
};
|
||||
}
|
||||
|
||||
const session = sessions.find((entry) => entry.sessionId === sessionId);
|
||||
if (!session) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Terminal session "${sessionId}" is not in the current AI scope.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!scopeCtx.readTerminalContext) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Terminal context reader is unavailable for this AI scope.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await scopeCtx.readTerminalContext({
|
||||
sessionId,
|
||||
range: typeof args.range === 'string' ? args.range as TerminalContextReadRange : undefined,
|
||||
startLine: typeof args.startLine === 'number' ? args.startLine : undefined,
|
||||
maxLines: typeof args.maxLines === 'number' ? args.maxLines : undefined,
|
||||
});
|
||||
if (result.ok === false) return result;
|
||||
const normalizedResult = result;
|
||||
const fingerprint = toolResultDedup?.fingerprintFor(
|
||||
spec.toolName,
|
||||
hashScopeKey([
|
||||
sessionId,
|
||||
String(normalizedResult.startLine),
|
||||
String(normalizedResult.endLine),
|
||||
normalizedResult.range,
|
||||
hashToolResult(normalizedResult.content),
|
||||
]),
|
||||
);
|
||||
return fingerprint
|
||||
? applyToolDedup(spec.toolName, fingerprint, normalizedResult, toolResultDedup)
|
||||
: normalizedResult;
|
||||
}
|
||||
case 'harness.web.search': {
|
||||
const { query, maxResults } = args as { query: string; maxResults?: number };
|
||||
return unwrap(await executeWebSearch(deps, { query, maxResults }));
|
||||
}
|
||||
case 'harness.url.fetch': {
|
||||
const { url, maxLength } = args as { url: string; maxLength?: number };
|
||||
const fingerprint = toolResultDedup?.fingerprintFor(spec.toolName, url);
|
||||
const raw = unwrap(await executeUrlFetch(deps, { url, maxLength }));
|
||||
if (fingerprint) {
|
||||
return applyToolDedup(spec.toolName, fingerprint, raw, toolResultDedup);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
case 'harness.skill.run': {
|
||||
const { executeSkillRun } = await import('./builtinSkillRunner');
|
||||
const result = await executeSkillRun(deps, args, { chatSessionId });
|
||||
return unwrap(result);
|
||||
}
|
||||
default:
|
||||
return { error: `No local executor registered for "${spec.capabilityId}".` };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSessionQueueKey(
|
||||
spec: CattyToolSpec,
|
||||
args: Record<string, unknown>,
|
||||
chatSessionId?: string,
|
||||
): string | null {
|
||||
if (spec.capabilityId.startsWith('harness.') && !spec.policy.write) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionId = typeof args.sessionId === 'string' ? args.sessionId : undefined;
|
||||
if (sessionId) {
|
||||
return `${chatSessionId ?? 'global'}:${sessionId}`;
|
||||
}
|
||||
return `${chatSessionId ?? 'global'}:${spec.toolName}`;
|
||||
}
|
||||
|
||||
export function resolveSessionQueueKeyForTests(
|
||||
spec: Pick<CattyToolSpec, 'capabilityId' | 'toolName' | 'policy'>,
|
||||
args: Record<string, unknown>,
|
||||
chatSessionId?: string,
|
||||
): string | null {
|
||||
return resolveSessionQueueKey(spec as CattyToolSpec, args, chatSessionId);
|
||||
}
|
||||
|
||||
function createCatalogTool(spec: CattyToolSpec) {
|
||||
const inputSchema = buildZodObject(spec.inputShape);
|
||||
|
||||
return tool({
|
||||
description: spec.description,
|
||||
inputSchema,
|
||||
contextSchema: cattyToolContextSchema,
|
||||
execute: async (args, { toolCallId: _toolCallId, abortSignal, context }) => {
|
||||
const toolContext = context as CattyToolContext;
|
||||
const deps = toolDepsFromContext(toolContext);
|
||||
const { toolOutputStore, toolResultDedup } = toolContext;
|
||||
|
||||
const queueKey = resolveSessionQueueKey(
|
||||
spec,
|
||||
args as Record<string, unknown>,
|
||||
deps.chatSessionId,
|
||||
);
|
||||
const slot = queueKey ? reserveSessionSlot(queueKey) : null;
|
||||
|
||||
try {
|
||||
const result = await (async () => {
|
||||
if (abortSignal?.aborted) {
|
||||
return { error: 'Tool call cancelled before it could start.' };
|
||||
}
|
||||
|
||||
await slot?.ready;
|
||||
|
||||
if (spec.capabilityId === 'terminal.execute') {
|
||||
const { sessionId: sid, command } = args as { sessionId: string; command: string };
|
||||
const writeFingerprint = buildTerminalWriteFingerprint(
|
||||
'terminal_execute',
|
||||
deps.chatSessionId,
|
||||
{ sessionId: sid, command },
|
||||
);
|
||||
const replay = writeFingerprint
|
||||
? toolResultDedup?.replayCompletedWrite(writeFingerprint)
|
||||
: undefined;
|
||||
if (replay !== undefined) {
|
||||
return {
|
||||
...(typeof replay === 'object' && replay !== null ? replay : { result: replay }),
|
||||
replayedCompletedResult: true,
|
||||
note: 'The command already executed before request compaction; its recorded result was replayed and the command was not executed again.',
|
||||
};
|
||||
}
|
||||
const cancelOnAbort = () => {
|
||||
if (deps.chatSessionId) {
|
||||
void deps.bridge.aiCattyCancelExec?.(deps.chatSessionId);
|
||||
}
|
||||
};
|
||||
abortSignal?.addEventListener('abort', cancelOnAbort, { once: true });
|
||||
try {
|
||||
const result = await executeTerminalExecute(deps, { sessionId: sid, command });
|
||||
if (result.ok === false) {
|
||||
if (!result.data) return unwrap(result);
|
||||
const fittedFailure = {
|
||||
error: fitLargeToolResultForModel({
|
||||
result: result.error,
|
||||
capabilityId: 'terminal.execute.error',
|
||||
chatSessionId: deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
terminalSessionId: sid,
|
||||
normalizeStrings: true,
|
||||
}),
|
||||
...fitTerminalExecuteResultForModel({
|
||||
...result.data,
|
||||
command,
|
||||
sessionId: sid,
|
||||
}, {
|
||||
chatSessionId: deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
}),
|
||||
};
|
||||
if (writeFingerprint) toolResultDedup?.rememberCompletedWrite(writeFingerprint, fittedFailure);
|
||||
return fittedFailure;
|
||||
}
|
||||
const fitted = fitTerminalExecuteResultForModel({
|
||||
...result.data,
|
||||
command,
|
||||
sessionId: sid,
|
||||
}, {
|
||||
chatSessionId: deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
});
|
||||
if (writeFingerprint) toolResultDedup?.rememberCompletedWrite(writeFingerprint, fitted);
|
||||
return fitted;
|
||||
} finally {
|
||||
abortSignal?.removeEventListener('abort', cancelOnAbort);
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.localExecution || spec.capabilityId.startsWith('harness.')) {
|
||||
const result = await executeLocalCattyCapability({
|
||||
deps,
|
||||
spec,
|
||||
args: args as Record<string, unknown>,
|
||||
toolOutputStore,
|
||||
toolResultDedup,
|
||||
chatSessionId: deps.chatSessionId,
|
||||
});
|
||||
return fitCapabilityResultForModel(
|
||||
result,
|
||||
spec,
|
||||
deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
args as Record<string, unknown>,
|
||||
toolResultDedup,
|
||||
);
|
||||
}
|
||||
|
||||
if (!spec.rpcMethod) {
|
||||
return { error: `Capability "${spec.capabilityId}" has no RPC binding.` };
|
||||
}
|
||||
|
||||
const terminalStartFingerprint = spec.capabilityId === 'terminal.start'
|
||||
? buildTerminalWriteFingerprint(
|
||||
'terminal_start',
|
||||
deps.chatSessionId,
|
||||
args as { sessionId?: unknown; command?: unknown },
|
||||
)
|
||||
: undefined;
|
||||
const terminalStartReplay = terminalStartFingerprint
|
||||
? toolResultDedup?.replayCompletedWrite(terminalStartFingerprint)
|
||||
: undefined;
|
||||
if (terminalStartReplay !== undefined) {
|
||||
return {
|
||||
...(typeof terminalStartReplay === 'object' && terminalStartReplay !== null
|
||||
? terminalStartReplay
|
||||
: { result: terminalStartReplay }),
|
||||
replayedCompletedResult: true,
|
||||
note: 'The background command already started before request compaction; its recorded job was replayed and no second job was created.',
|
||||
};
|
||||
}
|
||||
|
||||
let raw = await invokeCapabilityRpc(
|
||||
deps.bridge,
|
||||
spec.rpcMethod,
|
||||
args as Record<string, unknown>,
|
||||
deps.chatSessionId,
|
||||
);
|
||||
if (
|
||||
spec.capabilityId === 'terminal.start'
|
||||
&& raw && typeof raw === 'object'
|
||||
&& typeof (raw as { jobId?: unknown }).jobId === 'string'
|
||||
&& typeof (args as { sessionId?: unknown }).sessionId === 'string'
|
||||
) {
|
||||
toolResultDedup?.rememberTerminalJobSession(
|
||||
(raw as { jobId: string }).jobId,
|
||||
(args as { sessionId: string }).sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
spec.capabilityId === 'terminal.poll'
|
||||
&& raw
|
||||
&& typeof raw === 'object'
|
||||
&& (raw as { ok?: boolean }).ok !== false
|
||||
) {
|
||||
let poll = raw as Record<string, unknown>;
|
||||
const monitorKey = `${deps.chatSessionId ?? 'global'}:${String(poll.jobId ?? args.jobId ?? '')}`;
|
||||
if (
|
||||
isStreamingMonitorCommand(poll.command)
|
||||
&& typeof poll.output === 'string'
|
||||
&& poll.output.trim().length > 0
|
||||
) {
|
||||
const guarded = globalTerminalMonitorGuard.process(monitorKey, poll.output);
|
||||
if (guarded.action === 'stop') {
|
||||
const stopResult = await invokeCapabilityRpc(
|
||||
deps.bridge,
|
||||
'netcatty/jobStop',
|
||||
{ jobId: poll.jobId ?? args.jobId },
|
||||
deps.chatSessionId,
|
||||
);
|
||||
poll = applyMonitorStopResult(poll, stopResult, guarded.suppressedCount);
|
||||
} else if (guarded.action === 'suppress') {
|
||||
poll = {
|
||||
...poll,
|
||||
output: `[monitor batch suppressed by rate limit; suppressed=${guarded.suppressedCount}]`,
|
||||
};
|
||||
} else {
|
||||
let output = guarded.content;
|
||||
if (guarded.sourceTruncated && toolOutputStore && deps.chatSessionId) {
|
||||
const jobId = String(poll.jobId ?? args.jobId ?? '');
|
||||
const handle = toolOutputStore.store({
|
||||
chatSessionId: deps.chatSessionId,
|
||||
capabilityId: 'terminal.poll.monitor-batch',
|
||||
content: poll.output,
|
||||
sessionId: jobId ? toolResultDedup?.terminalSessionForJob(jobId) : undefined,
|
||||
});
|
||||
output += [
|
||||
'',
|
||||
`[monitor batch archived before shortening: rawChars=${poll.output.length} handleId=${handle.id}]`,
|
||||
'Use tool_output_read with this handleId to search/read omitted details; the terminal nextOffset already advances past the raw batch.',
|
||||
'This saved output is available only until the app closes. Read this handle before closing the app.',
|
||||
].join('\n');
|
||||
}
|
||||
poll = { ...poll, output };
|
||||
}
|
||||
}
|
||||
if (poll.status !== 'running' && poll.status !== 'stopping') {
|
||||
globalTerminalMonitorGuard.clear(monitorKey);
|
||||
}
|
||||
raw = poll;
|
||||
const fingerprint = toolResultDedup?.fingerprintFor(
|
||||
spec.toolName,
|
||||
hashScopeKey([
|
||||
String(poll.jobId ?? args.jobId ?? ''),
|
||||
String(poll.outputBaseOffset ?? ''),
|
||||
String(poll.nextOffset ?? ''),
|
||||
String(poll.status ?? ''),
|
||||
hashToolResult(poll.output ?? ''),
|
||||
]),
|
||||
);
|
||||
if (fingerprint) {
|
||||
return fitCapabilityResultForModel(
|
||||
applyToolDedup(spec.toolName, fingerprint, poll, toolResultDedup),
|
||||
spec,
|
||||
deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
args as Record<string, unknown>,
|
||||
toolResultDedup,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.toolName === 'get_environment' || spec.capabilityId === 'session.environment') {
|
||||
const ctx = typeof deps.context === 'function' ? deps.context() : deps.context;
|
||||
const fingerprint = toolResultDedup?.fingerprintFor(
|
||||
spec.toolName,
|
||||
hashScopeKey([deps.chatSessionId, ctx.workspaceId, String(ctx.sessions?.length ?? 0)]),
|
||||
);
|
||||
if (fingerprint) {
|
||||
return fitCapabilityResultForModel(
|
||||
applyToolDedup(spec.toolName, fingerprint, raw, toolResultDedup),
|
||||
spec,
|
||||
deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
args as Record<string, unknown>,
|
||||
toolResultDedup,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.capabilityId.includes('sftp') && spec.capabilityId.includes('read')) {
|
||||
const { sessionId: sid, path } = args as { sessionId?: string; path?: string };
|
||||
const fingerprint = toolResultDedup?.fingerprintFor(
|
||||
spec.toolName,
|
||||
hashScopeKey([sid, path]),
|
||||
);
|
||||
if (fingerprint) {
|
||||
return fitCapabilityResultForModel(
|
||||
applyToolDedup(spec.toolName, fingerprint, raw, toolResultDedup),
|
||||
spec,
|
||||
deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
args as Record<string, unknown>,
|
||||
toolResultDedup,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
raw
|
||||
&& typeof raw === 'object'
|
||||
&& 'content' in raw
|
||||
&& toolOutputStore
|
||||
&& deps.chatSessionId
|
||||
) {
|
||||
const content = String((raw as { content?: string }).content ?? '');
|
||||
const MAX_LIVE_SFTP_READ_CHARS = 24_000;
|
||||
if (content.length > MAX_LIVE_SFTP_READ_CHARS) {
|
||||
const handle = toolOutputStore.store({
|
||||
chatSessionId: deps.chatSessionId,
|
||||
capabilityId: spec.capabilityId,
|
||||
content,
|
||||
sessionId: sid,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
path: (raw as { path?: string }).path ?? path,
|
||||
preview: handle.preview,
|
||||
totalChars: handle.totalChars,
|
||||
handleId: handle.id,
|
||||
note: 'Full file content is available only until the app closes. Use tool_output_read now.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fittedRaw = fitCapabilityResultForModel(
|
||||
raw,
|
||||
spec,
|
||||
deps.chatSessionId,
|
||||
toolOutputStore,
|
||||
args as Record<string, unknown>,
|
||||
toolResultDedup,
|
||||
);
|
||||
if (
|
||||
terminalStartFingerprint
|
||||
&& raw && typeof raw === 'object'
|
||||
&& (raw as { ok?: boolean }).ok !== false
|
||||
&& typeof (raw as { jobId?: unknown }).jobId === 'string'
|
||||
) {
|
||||
toolResultDedup?.rememberCompletedWrite(terminalStartFingerprint, fittedRaw);
|
||||
}
|
||||
return fittedRaw;
|
||||
})();
|
||||
if (toolOutputStore && deps.chatSessionId) {
|
||||
await toolOutputStore.flush(deps.chatSessionId);
|
||||
return toolOutputStore.resolveRestartPersistenceNotices(result, deps.chatSessionId);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
slot?.release();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCattyToolContext(input: {
|
||||
bridge: NetcattyBridge;
|
||||
context: ToolDeps['context'];
|
||||
commandBlocklist?: string[];
|
||||
permissionMode: AIPermissionMode;
|
||||
webSearchConfig?: WebSearchConfig;
|
||||
chatSessionId?: string;
|
||||
toolOutputStore?: ToolOutputStore;
|
||||
toolResultDedup?: ToolResultDedup;
|
||||
}): CattyToolContext {
|
||||
return {
|
||||
bridge: input.bridge,
|
||||
chatSessionId: input.chatSessionId,
|
||||
permissionMode: input.permissionMode,
|
||||
commandBlocklist: input.commandBlocklist,
|
||||
webSearchConfig: input.webSearchConfig,
|
||||
getExecutorContext: typeof input.context === 'function'
|
||||
? input.context as () => ExecutorContext
|
||||
: () => input.context as ExecutorContext,
|
||||
toolOutputStore: input.toolOutputStore,
|
||||
toolResultDedup: input.toolResultDedup,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCattyToolsFromCatalog(
|
||||
bridge: NetcattyBridge,
|
||||
context: ToolDeps['context'],
|
||||
commandBlocklist?: string[],
|
||||
permissionMode: AIPermissionMode = 'confirm',
|
||||
webSearchConfig?: WebSearchConfig,
|
||||
chatSessionId?: string,
|
||||
toolOutputStore?: ToolOutputStore,
|
||||
toolResultDedup?: ToolResultDedup,
|
||||
): CattyToolsBundle {
|
||||
const sharedContext = buildCattyToolContext({
|
||||
bridge,
|
||||
context,
|
||||
commandBlocklist,
|
||||
permissionMode,
|
||||
webSearchConfig,
|
||||
chatSessionId,
|
||||
toolOutputStore,
|
||||
toolResultDedup,
|
||||
});
|
||||
|
||||
const catalogTools: Record<string, ReturnType<typeof tool>> = {};
|
||||
const toolsContext: Record<string, CattyToolContext> = {};
|
||||
|
||||
for (const rawSpec of cattyToolSpecs as CattyToolSpec[]) {
|
||||
if (rawSpec.capabilityId === 'harness.web.search' && !isWebSearchReady(webSearchConfig)) {
|
||||
continue;
|
||||
}
|
||||
catalogTools[rawSpec.toolName] = createCatalogTool(rawSpec);
|
||||
toolsContext[rawSpec.toolName] = sharedContext;
|
||||
}
|
||||
|
||||
return { tools: catalogTools, toolsContext };
|
||||
}
|
||||
|
||||
/** Test helper: attach shared context when calling tool.execute directly. */
|
||||
export function withCattyToolContext<T extends { execute: (...args: never[]) => unknown }>(
|
||||
toolInstance: T,
|
||||
context: CattyToolContext,
|
||||
toolCallId = 'test-call',
|
||||
): T {
|
||||
const original = toolInstance.execute.bind(toolInstance);
|
||||
return {
|
||||
...toolInstance,
|
||||
execute: (input: Parameters<T['execute']>[0], options?: Partial<Parameters<T['execute']>[1]>) =>
|
||||
original(input, {
|
||||
toolCallId,
|
||||
messages: [],
|
||||
...options,
|
||||
context,
|
||||
} as Parameters<T['execute']>[1]),
|
||||
} as T;
|
||||
}
|
||||
|
||||
export { tryFetchHostEnvironment };
|
||||
Reference in New Issue
Block a user