Add unit tests for skill capabilities, skill planner, and UV setup
- Implement tests for random ID generation, ensuring preference for crypto.randomUUID. - Create tests for runtime context capabilities, validating the injection of enabled skill capabilities. - Add tests for skill capability parsing, including classification and command example extraction. - Introduce tests for the skill planner, verifying tool call planning based on user requests and attachment requirements. - Establish tests for UV setup, ensuring proper handling of Python installation scenarios and environment checks.
This commit is contained in:
483
electron/gateway/tool-runtime.ts
Normal file
483
electron/gateway/tool-runtime.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import type { GatewayToolResultContentBlock } from '@electron/providers/BaseProvider';
|
||||
import type {
|
||||
AttachedFileMeta,
|
||||
RawMessage,
|
||||
ToolArtifact,
|
||||
ToolErrorInfo,
|
||||
ToolLifecycleStatus,
|
||||
ToolRenderHints,
|
||||
ToolResultPayload,
|
||||
} from '@runtime/shared/chat-model';
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export type ToolRuntimePreflightStatus = 'ready' | 'blocked';
|
||||
export type ToolRuntimeTerminalStatus = Exclude<ToolLifecycleStatus, 'running'>;
|
||||
export type ToolRuntimeSource = 'planner' | 'provider' | 'manual';
|
||||
export type ToolRuntimePhase = 'preflight' | 'execute' | 'normalize';
|
||||
|
||||
export interface ToolRuntimeLifecycleEvent {
|
||||
phase: ToolRuntimePhase;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
summary?: string;
|
||||
ok?: boolean;
|
||||
error?: ToolErrorInfo;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolRuntimeContext {
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
signal?: AbortSignal;
|
||||
workingDirectory?: string;
|
||||
files?: AttachedFileMeta[];
|
||||
metadata?: Record<string, unknown>;
|
||||
emitStatus?: (event: ToolRuntimeLifecycleEvent) => void;
|
||||
}
|
||||
|
||||
export interface ToolRuntimeInvocation {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
summary?: string;
|
||||
source?: ToolRuntimeSource;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolRuntimePreflightResult {
|
||||
ok: boolean;
|
||||
status: ToolRuntimePreflightStatus;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
summary?: string;
|
||||
normalizedInput?: unknown;
|
||||
warnings?: string[];
|
||||
missing?: string[];
|
||||
error?: ToolErrorInfo;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolRuntimeExecutionResult {
|
||||
ok: boolean;
|
||||
status: ToolRuntimeTerminalStatus;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
normalizedInput?: unknown;
|
||||
summary?: string;
|
||||
raw?: unknown;
|
||||
files?: AttachedFileMeta[];
|
||||
artifacts?: ToolArtifact[];
|
||||
logs?: Array<string | Record<string, unknown>>;
|
||||
error?: ToolErrorInfo;
|
||||
retryable?: boolean;
|
||||
skillType?: string;
|
||||
renderHints?: ToolRenderHints;
|
||||
metadata?: Record<string, unknown>;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface ToolRuntimeNormalizedResult {
|
||||
ok: boolean;
|
||||
status: ToolRuntimeTerminalStatus;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
summary?: string;
|
||||
payload: ToolResultPayload;
|
||||
block: GatewayToolResultContentBlock;
|
||||
transcriptMessage: RawMessage;
|
||||
}
|
||||
|
||||
export interface ToolRuntimeRunResult {
|
||||
preflight: ToolRuntimePreflightResult;
|
||||
execution: ToolRuntimeExecutionResult;
|
||||
normalized: ToolRuntimeNormalizedResult;
|
||||
}
|
||||
|
||||
export interface ToolRuntimeAdapter {
|
||||
readonly toolName: string;
|
||||
matchesTool?(toolName: string): boolean;
|
||||
preflight(
|
||||
invocation: ToolRuntimeInvocation,
|
||||
context: ToolRuntimeContext
|
||||
): MaybePromise<ToolRuntimePreflightResult>;
|
||||
execute(
|
||||
invocation: ToolRuntimeInvocation,
|
||||
context: ToolRuntimeContext
|
||||
): MaybePromise<ToolRuntimeExecutionResult>;
|
||||
normalize?(
|
||||
execution: ToolRuntimeExecutionResult,
|
||||
context: ToolRuntimeContext
|
||||
): MaybePromise<ToolRuntimeNormalizedResult>;
|
||||
}
|
||||
|
||||
function normalizeToolError(error: unknown, fallbackCode = 'tool_runtime_error'): ToolErrorInfo {
|
||||
if (typeof error === 'string') {
|
||||
return { code: fallbackCode, message: error };
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
code: fallbackCode,
|
||||
message: error.message,
|
||||
details: error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: fallbackCode,
|
||||
message: 'Unknown tool runtime error',
|
||||
details: error,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackSummary(execution: ToolRuntimeExecutionResult): string {
|
||||
if (execution.summary?.trim()) {
|
||||
return execution.summary;
|
||||
}
|
||||
|
||||
if (execution.error?.message?.trim()) {
|
||||
return execution.error.message;
|
||||
}
|
||||
|
||||
return execution.ok
|
||||
? `Tool ${execution.toolName} completed`
|
||||
: `Tool ${execution.toolName} failed`;
|
||||
}
|
||||
|
||||
function buildToolResultText(payload: ToolResultPayload, execution: ToolRuntimeExecutionResult): string {
|
||||
if (payload.summary?.trim()) {
|
||||
return payload.summary;
|
||||
}
|
||||
|
||||
if (typeof payload.error === 'string' && payload.error.trim()) {
|
||||
return payload.error;
|
||||
}
|
||||
|
||||
if (
|
||||
payload.error &&
|
||||
typeof payload.error === 'object' &&
|
||||
'message' in payload.error &&
|
||||
typeof payload.error.message === 'string' &&
|
||||
payload.error.message.trim()
|
||||
) {
|
||||
return payload.error.message;
|
||||
}
|
||||
|
||||
if (typeof payload.raw === 'string' && payload.raw.trim()) {
|
||||
return payload.raw;
|
||||
}
|
||||
|
||||
if (payload.logs?.length) {
|
||||
const firstLog = payload.logs[0];
|
||||
return typeof firstLog === 'string' ? firstLog : JSON.stringify(firstLog);
|
||||
}
|
||||
|
||||
return buildFallbackSummary(execution);
|
||||
}
|
||||
|
||||
export function buildToolResultPayload(
|
||||
execution: ToolRuntimeExecutionResult
|
||||
): ToolResultPayload {
|
||||
const summary = buildFallbackSummary(execution);
|
||||
|
||||
return {
|
||||
ok: execution.ok,
|
||||
summary,
|
||||
structuredData: execution.raw,
|
||||
files: execution.files,
|
||||
artifacts: execution.artifacts,
|
||||
logs: execution.logs,
|
||||
error: execution.error,
|
||||
retryable: execution.retryable,
|
||||
skillType: execution.skillType,
|
||||
renderHints: execution.renderHints,
|
||||
raw: execution.raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeToolExecutionResult(
|
||||
execution: ToolRuntimeExecutionResult
|
||||
): ToolRuntimeNormalizedResult {
|
||||
const payload = buildToolResultPayload(execution);
|
||||
const summary = payload.summary ?? buildFallbackSummary(execution);
|
||||
const block: GatewayToolResultContentBlock = {
|
||||
type: 'tool_result',
|
||||
toolCallId: execution.toolCallId,
|
||||
content: buildToolResultText(payload, execution),
|
||||
result: payload,
|
||||
summary,
|
||||
ok: payload.ok,
|
||||
error: payload.error,
|
||||
};
|
||||
|
||||
return {
|
||||
ok: execution.ok,
|
||||
status: execution.status,
|
||||
toolCallId: execution.toolCallId,
|
||||
toolName: execution.toolName,
|
||||
summary,
|
||||
payload,
|
||||
block,
|
||||
transcriptMessage: {
|
||||
role: 'tool_result',
|
||||
content: [block],
|
||||
timestamp: Date.now(),
|
||||
toolCallId: execution.toolCallId,
|
||||
toolName: execution.toolName,
|
||||
toolCall: {
|
||||
id: execution.toolCallId,
|
||||
name: execution.toolName,
|
||||
input: execution.normalizedInput,
|
||||
summary,
|
||||
},
|
||||
toolResult: payload,
|
||||
details: execution.raw,
|
||||
isError: !execution.ok,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createUnsupportedToolPreflightResult(
|
||||
invocation: ToolRuntimeInvocation
|
||||
): ToolRuntimePreflightResult {
|
||||
return {
|
||||
ok: false,
|
||||
status: 'blocked',
|
||||
toolCallId: invocation.toolCallId,
|
||||
toolName: invocation.toolName,
|
||||
summary: `No adapter registered for ${invocation.toolName}`,
|
||||
error: {
|
||||
code: 'tool_adapter_not_found',
|
||||
message: `No adapter registered for ${invocation.toolName}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createBlockedToolExecutionResult(
|
||||
preflight: ToolRuntimePreflightResult
|
||||
): ToolRuntimeExecutionResult {
|
||||
return {
|
||||
ok: false,
|
||||
status: 'error',
|
||||
toolCallId: preflight.toolCallId,
|
||||
toolName: preflight.toolName,
|
||||
normalizedInput: preflight.normalizedInput,
|
||||
summary: preflight.summary,
|
||||
error: preflight.error ?? {
|
||||
code: 'tool_preflight_blocked',
|
||||
message: preflight.summary || `Tool ${preflight.toolName} was blocked in preflight`,
|
||||
},
|
||||
metadata: preflight.metadata,
|
||||
durationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toolMatches(adapter: ToolRuntimeAdapter, toolName: string): boolean {
|
||||
return adapter.toolName === toolName || adapter.matchesTool?.(toolName) === true;
|
||||
}
|
||||
|
||||
function emitStatus(
|
||||
context: ToolRuntimeContext,
|
||||
event: ToolRuntimeLifecycleEvent
|
||||
): void {
|
||||
context.emitStatus?.(event);
|
||||
}
|
||||
|
||||
export class ToolRuntime {
|
||||
private readonly adapters: ToolRuntimeAdapter[];
|
||||
|
||||
constructor(adapters: ToolRuntimeAdapter[] = []) {
|
||||
this.adapters = [...adapters];
|
||||
}
|
||||
|
||||
register(adapter: ToolRuntimeAdapter): void {
|
||||
this.adapters.push(adapter);
|
||||
}
|
||||
|
||||
listAdapters(): ToolRuntimeAdapter[] {
|
||||
return [...this.adapters];
|
||||
}
|
||||
|
||||
resolveAdapter(toolName: string): ToolRuntimeAdapter | null {
|
||||
return this.adapters.find((adapter) => toolMatches(adapter, toolName)) ?? null;
|
||||
}
|
||||
|
||||
async preflight(
|
||||
invocation: ToolRuntimeInvocation,
|
||||
context: ToolRuntimeContext = {}
|
||||
): Promise<ToolRuntimePreflightResult> {
|
||||
const adapter = this.resolveAdapter(invocation.toolName);
|
||||
if (!adapter) {
|
||||
const blocked = createUnsupportedToolPreflightResult(invocation);
|
||||
emitStatus(context, {
|
||||
phase: 'preflight',
|
||||
toolCallId: blocked.toolCallId,
|
||||
toolName: blocked.toolName,
|
||||
summary: blocked.summary,
|
||||
ok: blocked.ok,
|
||||
error: blocked.error,
|
||||
});
|
||||
return blocked;
|
||||
}
|
||||
|
||||
try {
|
||||
const preflight = await adapter.preflight(invocation, context);
|
||||
emitStatus(context, {
|
||||
phase: 'preflight',
|
||||
toolCallId: preflight.toolCallId,
|
||||
toolName: preflight.toolName,
|
||||
summary: preflight.summary,
|
||||
ok: preflight.ok,
|
||||
error: preflight.error,
|
||||
metadata: preflight.metadata,
|
||||
});
|
||||
return preflight;
|
||||
} catch (error) {
|
||||
const normalizedError = normalizeToolError(error, 'tool_preflight_failed');
|
||||
const blocked: ToolRuntimePreflightResult = {
|
||||
ok: false,
|
||||
status: 'blocked',
|
||||
toolCallId: invocation.toolCallId,
|
||||
toolName: invocation.toolName,
|
||||
summary: normalizedError.message,
|
||||
error: normalizedError,
|
||||
};
|
||||
emitStatus(context, {
|
||||
phase: 'preflight',
|
||||
toolCallId: blocked.toolCallId,
|
||||
toolName: blocked.toolName,
|
||||
summary: blocked.summary,
|
||||
ok: blocked.ok,
|
||||
error: blocked.error,
|
||||
});
|
||||
return blocked;
|
||||
}
|
||||
}
|
||||
|
||||
async execute(
|
||||
invocation: ToolRuntimeInvocation,
|
||||
context: ToolRuntimeContext = {},
|
||||
preflight?: ToolRuntimePreflightResult
|
||||
): Promise<ToolRuntimeExecutionResult> {
|
||||
const resolvedPreflight = preflight ?? await this.preflight(invocation, context);
|
||||
if (!resolvedPreflight.ok || resolvedPreflight.status === 'blocked') {
|
||||
const blocked = createBlockedToolExecutionResult(resolvedPreflight);
|
||||
emitStatus(context, {
|
||||
phase: 'execute',
|
||||
toolCallId: blocked.toolCallId,
|
||||
toolName: blocked.toolName,
|
||||
summary: blocked.summary,
|
||||
ok: blocked.ok,
|
||||
error: blocked.error,
|
||||
metadata: blocked.metadata,
|
||||
});
|
||||
return blocked;
|
||||
}
|
||||
|
||||
const adapter = this.resolveAdapter(invocation.toolName);
|
||||
if (!adapter) {
|
||||
const blocked = createBlockedToolExecutionResult(
|
||||
createUnsupportedToolPreflightResult(invocation)
|
||||
);
|
||||
emitStatus(context, {
|
||||
phase: 'execute',
|
||||
toolCallId: blocked.toolCallId,
|
||||
toolName: blocked.toolName,
|
||||
summary: blocked.summary,
|
||||
ok: blocked.ok,
|
||||
error: blocked.error,
|
||||
});
|
||||
return blocked;
|
||||
}
|
||||
|
||||
const preparedInvocation: ToolRuntimeInvocation = {
|
||||
...invocation,
|
||||
input: resolvedPreflight.normalizedInput ?? invocation.input,
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const execution = await adapter.execute(preparedInvocation, context);
|
||||
const completed: ToolRuntimeExecutionResult = {
|
||||
...execution,
|
||||
toolCallId: execution.toolCallId || preparedInvocation.toolCallId,
|
||||
toolName: execution.toolName || preparedInvocation.toolName,
|
||||
normalizedInput: execution.normalizedInput ?? preparedInvocation.input,
|
||||
durationMs: execution.durationMs ?? Date.now() - startTime,
|
||||
};
|
||||
emitStatus(context, {
|
||||
phase: 'execute',
|
||||
toolCallId: completed.toolCallId,
|
||||
toolName: completed.toolName,
|
||||
summary: completed.summary,
|
||||
ok: completed.ok,
|
||||
error: completed.error,
|
||||
metadata: completed.metadata,
|
||||
});
|
||||
return completed;
|
||||
} catch (error) {
|
||||
const normalizedError = normalizeToolError(error, 'tool_execute_failed');
|
||||
const failed: ToolRuntimeExecutionResult = {
|
||||
ok: false,
|
||||
status: 'error',
|
||||
toolCallId: preparedInvocation.toolCallId,
|
||||
toolName: preparedInvocation.toolName,
|
||||
normalizedInput: preparedInvocation.input,
|
||||
summary: normalizedError.message,
|
||||
error: normalizedError,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
emitStatus(context, {
|
||||
phase: 'execute',
|
||||
toolCallId: failed.toolCallId,
|
||||
toolName: failed.toolName,
|
||||
summary: failed.summary,
|
||||
ok: failed.ok,
|
||||
error: failed.error,
|
||||
});
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
|
||||
async normalize(
|
||||
execution: ToolRuntimeExecutionResult,
|
||||
context: ToolRuntimeContext = {}
|
||||
): Promise<ToolRuntimeNormalizedResult> {
|
||||
const adapter = this.resolveAdapter(execution.toolName);
|
||||
const normalized = adapter?.normalize
|
||||
? await adapter.normalize(execution, context)
|
||||
: normalizeToolExecutionResult(execution);
|
||||
|
||||
emitStatus(context, {
|
||||
phase: 'normalize',
|
||||
toolCallId: normalized.toolCallId,
|
||||
toolName: normalized.toolName,
|
||||
summary: normalized.summary,
|
||||
ok: normalized.ok,
|
||||
error: typeof normalized.payload.error === 'string'
|
||||
? { message: normalized.payload.error }
|
||||
: normalized.payload.error,
|
||||
});
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async run(
|
||||
invocation: ToolRuntimeInvocation,
|
||||
context: ToolRuntimeContext = {}
|
||||
): Promise<ToolRuntimeRunResult> {
|
||||
const preflight = await this.preflight(invocation, context);
|
||||
const execution = await this.execute(invocation, context, preflight);
|
||||
const normalized = await this.normalize(execution, context);
|
||||
return {
|
||||
preflight,
|
||||
execution,
|
||||
normalized,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createToolRuntime(adapters: ToolRuntimeAdapter[] = []): ToolRuntime {
|
||||
return new ToolRuntime(adapters);
|
||||
}
|
||||
Reference in New Issue
Block a user