mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-16 15:34:08 +08:00
664 lines
19 KiB
TypeScript
664 lines
19 KiB
TypeScript
import {
|
|
ERROR_CODES,
|
|
isJsonRpcNotification,
|
|
isJsonRpcRequest,
|
|
METHOD_ITEM_TOOL_CALL,
|
|
METHOD_TURN_COMPLETED,
|
|
type AppServerConnection,
|
|
type DynamicToolCallParams,
|
|
type DynamicToolCallPhase,
|
|
type DynamicToolCallResponse,
|
|
type JsonRpcMessage,
|
|
type JsonRpcRequest,
|
|
} from "@limecloud/app-server-client";
|
|
import { app } from "./electronRuntime";
|
|
import type {
|
|
BrowserToolCall,
|
|
BrowserToolResult,
|
|
ElectronBrowserTabHost,
|
|
} from "./browserTabHost";
|
|
import process from "node:process";
|
|
|
|
const THREAD_START_METHOD = "thread/start";
|
|
const THREAD_RESUME_METHOD = "thread/resume";
|
|
const DESKTOP_NAMESPACE = "desktop";
|
|
const APP_INFO_TOOL = "appInfo";
|
|
const BROWSER_NAMESPACE = "browser";
|
|
const TERMINAL_TURN_STATUSES = new Set([
|
|
"completed",
|
|
"failed",
|
|
"interrupted",
|
|
"canceled",
|
|
"cancelled",
|
|
]);
|
|
|
|
type DynamicToolConnection = Pick<
|
|
AppServerConnection,
|
|
"respondServerRequest" | "rejectServerRequest"
|
|
>;
|
|
|
|
export interface DynamicToolOwnerContext {
|
|
ownerWebContentsId: number;
|
|
}
|
|
|
|
type AppInfo = {
|
|
locale: string;
|
|
name: string;
|
|
platform: string;
|
|
version: string;
|
|
};
|
|
|
|
type DynamicToolBinding = {
|
|
namespace: string;
|
|
runtimeName: string;
|
|
tool: string;
|
|
};
|
|
|
|
type ThreadHostBinding = {
|
|
ownerWebContentsId: number;
|
|
tools: Map<string, DynamicToolBinding>;
|
|
};
|
|
|
|
type DynamicToolCallState = "awaitingApproval" | "completed";
|
|
|
|
const APP_INFO_BINDING = Object.freeze<DynamicToolBinding>({
|
|
namespace: DESKTOP_NAMESPACE,
|
|
runtimeName: `${DESKTOP_NAMESPACE}__${APP_INFO_TOOL}`,
|
|
tool: APP_INFO_TOOL,
|
|
});
|
|
|
|
const BROWSER_TOOL_DEFINITIONS = Object.freeze([
|
|
tool("openTabs", "List Browser tabs available to this conversation.", {}),
|
|
tool(
|
|
"newTab",
|
|
"Open a new Agent-controlled tab in the visible Browser workspace.",
|
|
{ url: stringProperty("Initial http or https URL.") },
|
|
["url"],
|
|
),
|
|
tool(
|
|
"claimTab",
|
|
"Claim an existing visible tab for the current turn.",
|
|
{
|
|
pageRevision: nonNegativeIntegerProperty(
|
|
"Page revision returned by openTabs.",
|
|
),
|
|
tabId: stringProperty("Tab id returned by openTabs."),
|
|
title: stringProperty("Title returned by openTabs.", { allowEmpty: true }),
|
|
url: stringProperty("URL returned by openTabs."),
|
|
},
|
|
["tabId", "title", "url", "pageRevision"],
|
|
),
|
|
tool(
|
|
"releaseTab",
|
|
"Release Agent control while keeping a user tab visible.",
|
|
{ tabId: stringProperty("Tab id to release.") },
|
|
["tabId"],
|
|
),
|
|
tool(
|
|
"goto",
|
|
"Navigate the claimed tab to an http or https URL.",
|
|
{
|
|
tabId: stringProperty("Claimed tab id; defaults to the selected tab."),
|
|
url: stringProperty("Destination URL."),
|
|
},
|
|
["url"],
|
|
),
|
|
tool(
|
|
"observe",
|
|
"Read the claimed tab URL, title, navigation state, and accessibility tree.",
|
|
{ tabId: stringProperty("Claimed tab id; defaults to the selected tab.") },
|
|
),
|
|
tool("screenshot", "Capture the claimed tab as a PNG image.", {
|
|
tabId: stringProperty("Claimed tab id; defaults to the selected tab."),
|
|
}),
|
|
tool(
|
|
"click",
|
|
"Click an actionable node from the latest observe result. Sensitive targets hand control to the user.",
|
|
{
|
|
backendNodeId: integerProperty("backendNodeId from observe."),
|
|
snapshotId: stringProperty("snapshotId from observe."),
|
|
tabId: stringProperty("Claimed tab id; defaults to the selected tab."),
|
|
},
|
|
["backendNodeId", "snapshotId"],
|
|
),
|
|
tool(
|
|
"fill",
|
|
"Replace text in an actionable field. Password or secret fields hand control to the user.",
|
|
{
|
|
backendNodeId: integerProperty("backendNodeId from observe."),
|
|
snapshotId: stringProperty("snapshotId from observe."),
|
|
tabId: stringProperty("Claimed tab id; defaults to the selected tab."),
|
|
text: stringProperty("Text to enter."),
|
|
},
|
|
["backendNodeId", "snapshotId", "text"],
|
|
),
|
|
tool(
|
|
"press",
|
|
"Press a non-submitting key in the claimed tab. Enter hands control to the user.",
|
|
{
|
|
key: stringProperty("DOM key value such as Escape or ArrowDown."),
|
|
snapshotId: stringProperty("snapshotId from observe."),
|
|
tabId: stringProperty("Claimed tab id; defaults to the selected tab."),
|
|
},
|
|
["key", "snapshotId"],
|
|
),
|
|
tool(
|
|
"markHandoff",
|
|
"Keep the claimed tab for a later turn and release control when this turn ends.",
|
|
{ tabId: stringProperty("Claimed tab id; defaults to the selected tab.") },
|
|
),
|
|
tool(
|
|
"markDeliverable",
|
|
"Keep the claimed tab as a user-visible deliverable when this turn ends.",
|
|
{ tabId: stringProperty("Claimed tab id; defaults to the selected tab.") },
|
|
),
|
|
tool(
|
|
"openArtifact",
|
|
"Open a completed Browser download by its controlled artifact ref.",
|
|
{ artifactRef: stringProperty("Artifact ref returned by a completed download.") },
|
|
["artifactRef"],
|
|
),
|
|
tool(
|
|
"revealArtifact",
|
|
"Reveal a completed Browser download in the system file manager.",
|
|
{ artifactRef: stringProperty("Artifact ref returned by a completed download.") },
|
|
["artifactRef"],
|
|
),
|
|
tool(
|
|
"copyArtifactRef",
|
|
"Copy a controlled artifact ref to the system clipboard.",
|
|
{ artifactRef: stringProperty("Artifact ref returned by a completed download.") },
|
|
["artifactRef"],
|
|
),
|
|
tool(
|
|
"readClipboard",
|
|
"Read the system clipboard after turn-scoped approval.",
|
|
{},
|
|
),
|
|
tool(
|
|
"writeClipboard",
|
|
"Write text to the system clipboard after turn-scoped approval.",
|
|
{ text: stringProperty("Text to write to the clipboard.") },
|
|
["text"],
|
|
),
|
|
tool(
|
|
"grantPermission",
|
|
"Grant a pending Browser permission for this turn after explicit approval.",
|
|
{
|
|
permission: stringProperty("Permission name from browser-tab-permission-request."),
|
|
requestId: stringProperty("Permission request id."),
|
|
},
|
|
["permission", "requestId"],
|
|
),
|
|
tool(
|
|
"uploadArtifact",
|
|
"Upload a completed Browser artifact into the current file input after approval.",
|
|
{
|
|
artifactRef: stringProperty("Artifact ref returned by a completed download."),
|
|
backendNodeId: integerProperty("File input backendNodeId from observe."),
|
|
snapshotId: stringProperty("snapshotId from observe."),
|
|
tabId: stringProperty("Claimed tab id; defaults to the selected tab."),
|
|
},
|
|
["artifactRef", "backendNodeId", "snapshotId"],
|
|
),
|
|
]);
|
|
|
|
const DESKTOP_DYNAMIC_TOOLS = Object.freeze([
|
|
Object.freeze({
|
|
type: "namespace",
|
|
name: DESKTOP_NAMESPACE,
|
|
description: "Read information exposed by the Lime desktop host.",
|
|
tools: Object.freeze([
|
|
tool(
|
|
APP_INFO_TOOL,
|
|
"Read the desktop application name, version, locale, and platform.",
|
|
{},
|
|
),
|
|
]),
|
|
}),
|
|
Object.freeze({
|
|
type: "namespace",
|
|
name: BROWSER_NAMESPACE,
|
|
description:
|
|
"Operate the same Browser workspace tab that is visible to the user.",
|
|
tools: BROWSER_TOOL_DEFINITIONS,
|
|
}),
|
|
]);
|
|
|
|
const BROWSER_BINDINGS = new Map<string, DynamicToolBinding>(
|
|
BROWSER_TOOL_DEFINITIONS.map((definition) => {
|
|
const binding = {
|
|
namespace: BROWSER_NAMESPACE,
|
|
runtimeName: `${BROWSER_NAMESPACE}__${definition.name}`,
|
|
tool: definition.name,
|
|
};
|
|
return [binding.runtimeName, binding];
|
|
}),
|
|
);
|
|
|
|
export class AppServerDynamicToolHost {
|
|
readonly #bindingsByThread = new Map<string, ThreadHostBinding>();
|
|
readonly #browserHost: ElectronBrowserTabHost | null;
|
|
readonly #callStates = new Map<string, DynamicToolCallState>();
|
|
readonly #readAppInfo: () => AppInfo;
|
|
|
|
constructor(
|
|
readAppInfo: () => AppInfo = () => ({
|
|
locale: app.getLocale(),
|
|
name: app.getName(),
|
|
platform: process.platform,
|
|
version: app.getVersion(),
|
|
}),
|
|
browserHost: ElectronBrowserTabHost | null = null,
|
|
) {
|
|
this.#readAppInfo = readAppInfo;
|
|
this.#browserHost = browserHost;
|
|
}
|
|
|
|
prepareClientRequest(message: JsonRpcRequest): JsonRpcRequest {
|
|
if (message.method !== THREAD_START_METHOD) {
|
|
return message;
|
|
}
|
|
const params = asRecord(message.params) ?? {};
|
|
return {
|
|
...message,
|
|
params: {
|
|
...params,
|
|
dynamicTools: structuredClone(DESKTOP_DYNAMIC_TOOLS),
|
|
},
|
|
};
|
|
}
|
|
|
|
observeClientResult(
|
|
method: string,
|
|
result: unknown,
|
|
owner: DynamicToolOwnerContext | null = null,
|
|
): void {
|
|
if (method !== THREAD_START_METHOD && method !== THREAD_RESUME_METHOD) {
|
|
return;
|
|
}
|
|
const thread = asRecord(asRecord(result)?.thread);
|
|
const threadId = nonEmptyString(thread?.id);
|
|
if (!threadId) {
|
|
return;
|
|
}
|
|
const tools = new Map<string, DynamicToolBinding>([
|
|
[APP_INFO_BINDING.runtimeName, APP_INFO_BINDING],
|
|
...BROWSER_BINDINGS,
|
|
]);
|
|
this.#bindingsByThread.set(threadId, {
|
|
ownerWebContentsId: owner?.ownerWebContentsId ?? 0,
|
|
tools,
|
|
});
|
|
}
|
|
|
|
observeServerMessage(message: JsonRpcMessage): void {
|
|
if (
|
|
!this.#browserHost ||
|
|
!isJsonRpcNotification(message) ||
|
|
message.method !== METHOD_TURN_COMPLETED
|
|
) {
|
|
return;
|
|
}
|
|
const params = asRecord(message.params);
|
|
const threadId = nonEmptyString(params?.threadId);
|
|
const turn = asRecord(params?.turn);
|
|
const turnId = nonEmptyString(params?.turnId) ?? nonEmptyString(turn?.id);
|
|
const status = nonEmptyString(turn?.status);
|
|
if (threadId && turnId && status && TERMINAL_TURN_STATUSES.has(status)) {
|
|
const prefix = `${threadId}\u0000${turnId}\u0000`;
|
|
for (const key of this.#callStates.keys()) {
|
|
if (key.startsWith(prefix)) {
|
|
this.#callStates.delete(key);
|
|
}
|
|
}
|
|
this.#browserHost.turnEnded(threadId, turnId);
|
|
}
|
|
}
|
|
|
|
async tryHandle(
|
|
connection: DynamicToolConnection,
|
|
message: JsonRpcMessage,
|
|
): Promise<boolean> {
|
|
if (
|
|
!isJsonRpcRequest(message) ||
|
|
message.method !== METHOD_ITEM_TOOL_CALL
|
|
) {
|
|
return false;
|
|
}
|
|
const params = parseCallParams(message.params);
|
|
if (!params.ok) {
|
|
connection.rejectServerRequest(message.id, {
|
|
code: ERROR_CODES.invalidParams,
|
|
message: params.error,
|
|
});
|
|
return true;
|
|
}
|
|
const threadBinding = this.#bindingsByThread.get(params.value.threadId);
|
|
const runtimeName = `${params.value.namespace}__${params.value.tool}`;
|
|
const binding = threadBinding?.tools.get(runtimeName);
|
|
if (
|
|
!threadBinding ||
|
|
!binding ||
|
|
params.value.namespace !== binding.namespace ||
|
|
params.value.tool !== binding.tool
|
|
) {
|
|
connection.rejectServerRequest(message.id, {
|
|
code: ERROR_CODES.invalidParams,
|
|
message:
|
|
"item/tool/call does not match a frozen host capability binding",
|
|
});
|
|
return true;
|
|
}
|
|
const validationError = validateArguments(binding, params.value.arguments);
|
|
if (validationError) {
|
|
connection.rejectServerRequest(message.id, {
|
|
code: ERROR_CODES.invalidParams,
|
|
message: validationError,
|
|
});
|
|
return true;
|
|
}
|
|
const callKey = [
|
|
params.value.threadId,
|
|
params.value.turnId,
|
|
params.value.callId,
|
|
].join("\u0000");
|
|
const callState = this.#callStates.get(callKey);
|
|
const validTransition =
|
|
(params.value.phase === "preflight" && callState === undefined) ||
|
|
(params.value.phase === "approvedExecute" &&
|
|
callState === "awaitingApproval");
|
|
if (!validTransition) {
|
|
connection.rejectServerRequest(message.id, {
|
|
code: ERROR_CODES.invalidParams,
|
|
message: "item/tool/call phase is stale or already consumed",
|
|
});
|
|
return true;
|
|
}
|
|
this.#callStates.set(callKey, "completed");
|
|
|
|
try {
|
|
if (
|
|
binding.namespace !== BROWSER_NAMESPACE &&
|
|
params.value.phase !== "preflight"
|
|
) {
|
|
throw new Error(
|
|
"Desktop host capability does not support approved execution",
|
|
);
|
|
}
|
|
const response =
|
|
binding.namespace === BROWSER_NAMESPACE
|
|
? await this.#executeBrowserTool(
|
|
threadBinding.ownerWebContentsId,
|
|
params.value,
|
|
)
|
|
: appInfoResponse(this.#readAppInfo());
|
|
if (response.approval) {
|
|
if (
|
|
binding.namespace !== BROWSER_NAMESPACE ||
|
|
params.value.phase !== "preflight"
|
|
) {
|
|
throw new Error("Host returned approval outside Browser preflight");
|
|
}
|
|
this.#callStates.set(callKey, "awaitingApproval");
|
|
}
|
|
connection.respondServerRequest<DynamicToolCallResponse>(
|
|
message.id,
|
|
response,
|
|
);
|
|
} catch (error) {
|
|
connection.respondServerRequest<DynamicToolCallResponse>(message.id, {
|
|
contentItems: [
|
|
{
|
|
type: "inputText",
|
|
text: `desktop host capability failed: ${errorMessage(error)}`,
|
|
},
|
|
],
|
|
success: false,
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
reset(): void {
|
|
this.#bindingsByThread.clear();
|
|
this.#callStates.clear();
|
|
}
|
|
|
|
connectionLost(reason?: string): void {
|
|
this.#browserHost?.connectionLost(reason);
|
|
}
|
|
|
|
async #executeBrowserTool(
|
|
ownerWebContentsId: number,
|
|
params: DynamicToolCallParams & { arguments: Record<string, unknown> },
|
|
): Promise<DynamicToolCallResponse> {
|
|
if (!this.#browserHost || ownerWebContentsId <= 0) {
|
|
throw new Error("Browser workspace is not bound to this desktop thread");
|
|
}
|
|
const call: BrowserToolCall = {
|
|
...(params.approvalToken ? { approvalToken: params.approvalToken } : {}),
|
|
arguments: params.arguments,
|
|
callId: params.callId,
|
|
ownerWebContentsId,
|
|
phase: params.phase,
|
|
threadId: params.threadId,
|
|
tool: params.tool,
|
|
turnId: params.turnId,
|
|
};
|
|
const result = await this.#browserHost.executeTool(call);
|
|
return browserToolResponse(result);
|
|
}
|
|
}
|
|
|
|
type ParsedCallParams =
|
|
| {
|
|
ok: true;
|
|
value: DynamicToolCallParams & { arguments: Record<string, unknown> };
|
|
}
|
|
| { ok: false; error: string };
|
|
|
|
function parseCallParams(value: unknown): ParsedCallParams {
|
|
const params = asRecord(value);
|
|
const threadId = nonEmptyString(params?.threadId);
|
|
const turnId = nonEmptyString(params?.turnId);
|
|
const callId = nonEmptyString(params?.callId);
|
|
const tool = nonEmptyString(params?.tool);
|
|
const namespace = nonEmptyString(params?.namespace);
|
|
const phase = dynamicToolCallPhase(params?.phase);
|
|
const approvalToken = optionalNonEmptyString(params?.approvalToken);
|
|
const argumentsValue = asRecord(params?.arguments);
|
|
if (
|
|
!threadId ||
|
|
!turnId ||
|
|
!callId ||
|
|
!tool ||
|
|
!namespace ||
|
|
!phase ||
|
|
!argumentsValue
|
|
) {
|
|
return {
|
|
ok: false,
|
|
error:
|
|
"item/tool/call requires threadId, turnId, callId, namespace, tool, phase, and object arguments",
|
|
};
|
|
}
|
|
if (
|
|
(phase === "preflight" && approvalToken !== null) ||
|
|
(phase === "approvedExecute" && approvalToken === null)
|
|
) {
|
|
return {
|
|
ok: false,
|
|
error:
|
|
"item/tool/call approvalToken must be absent for preflight and present for approvedExecute",
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
value: {
|
|
threadId,
|
|
turnId,
|
|
callId,
|
|
namespace,
|
|
tool,
|
|
arguments: argumentsValue,
|
|
phase,
|
|
...(approvalToken ? { approvalToken } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function validateArguments(
|
|
binding: DynamicToolBinding,
|
|
args: Record<string, unknown>,
|
|
): string | null {
|
|
if (binding.namespace === DESKTOP_NAMESPACE) {
|
|
return Object.keys(args).length === 0
|
|
? null
|
|
: `${binding.runtimeName} does not accept arguments`;
|
|
}
|
|
const definition = BROWSER_TOOL_DEFINITIONS.find(
|
|
(candidate) => candidate.name === binding.tool,
|
|
);
|
|
if (!definition) {
|
|
return `Unknown Browser tool binding: ${binding.tool}`;
|
|
}
|
|
const properties = asRecord(definition.inputSchema.properties) ?? {};
|
|
for (const key of Object.keys(args)) {
|
|
if (!(key in properties)) {
|
|
return `${binding.runtimeName} does not accept argument: ${key}`;
|
|
}
|
|
}
|
|
const required = Array.isArray(definition.inputSchema.required)
|
|
? definition.inputSchema.required
|
|
: [];
|
|
for (const key of required) {
|
|
if (!(key in args)) {
|
|
return `${binding.runtimeName} requires argument: ${key}`;
|
|
}
|
|
}
|
|
for (const [key, value] of Object.entries(args)) {
|
|
const schema = asRecord(properties[key]);
|
|
if (
|
|
schema?.type === "string" &&
|
|
schema.allowEmpty !== true &&
|
|
nonEmptyString(value) === null
|
|
) {
|
|
return `${binding.runtimeName}.${key} must be a non-empty string`;
|
|
}
|
|
if (
|
|
schema?.type === "integer" &&
|
|
(!Number.isInteger(value) ||
|
|
Number(value) <
|
|
(typeof schema.minimum === "number" ? schema.minimum : 1))
|
|
) {
|
|
return `${binding.runtimeName}.${key} must be an integer greater than or equal to ${typeof schema.minimum === "number" ? schema.minimum : 1}`;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function appInfoResponse(info: AppInfo): DynamicToolCallResponse {
|
|
return {
|
|
contentItems: [{ type: "inputText", text: JSON.stringify(info) }],
|
|
success: true,
|
|
};
|
|
}
|
|
|
|
function browserToolResponse(
|
|
result: BrowserToolResult,
|
|
): DynamicToolCallResponse {
|
|
if (result.approval) {
|
|
return {
|
|
approval: result.approval,
|
|
contentItems: [],
|
|
success: false,
|
|
};
|
|
}
|
|
const contentItems: DynamicToolCallResponse["contentItems"] = [
|
|
{
|
|
type: "inputText",
|
|
text: JSON.stringify({
|
|
status: result.status,
|
|
...(result.state ? { tab: result.state } : {}),
|
|
...(result.data !== undefined ? { data: result.data } : {}),
|
|
}),
|
|
},
|
|
];
|
|
if (result.imageBase64) {
|
|
contentItems.push({
|
|
type: "inputImage",
|
|
imageUrl: `data:image/png;base64,${result.imageBase64}`,
|
|
});
|
|
}
|
|
return {
|
|
contentItems,
|
|
success: result.status === "completed",
|
|
};
|
|
}
|
|
|
|
function dynamicToolCallPhase(value: unknown): DynamicToolCallPhase | null {
|
|
return value === "preflight" || value === "approvedExecute" ? value : null;
|
|
}
|
|
|
|
function optionalNonEmptyString(value: unknown): string | null {
|
|
if (value === undefined || value === null) {
|
|
return null;
|
|
}
|
|
return nonEmptyString(value);
|
|
}
|
|
|
|
function tool(
|
|
name: string,
|
|
description: string,
|
|
properties: Record<string, unknown>,
|
|
required: string[] = [],
|
|
) {
|
|
return Object.freeze({
|
|
type: "function" as const,
|
|
name,
|
|
description,
|
|
inputSchema: Object.freeze({
|
|
type: "object",
|
|
properties: Object.freeze(properties),
|
|
...(required.length > 0 ? { required: Object.freeze(required) } : {}),
|
|
additionalProperties: false,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function stringProperty(
|
|
description: string,
|
|
options: { allowEmpty?: boolean } = {},
|
|
) {
|
|
return Object.freeze({
|
|
type: "string",
|
|
description,
|
|
...(options.allowEmpty === true ? { allowEmpty: true } : {}),
|
|
});
|
|
}
|
|
|
|
function integerProperty(description: string) {
|
|
return Object.freeze({ type: "integer", minimum: 1, description });
|
|
}
|
|
|
|
function nonNegativeIntegerProperty(description: string) {
|
|
return Object.freeze({ type: "integer", minimum: 0, description });
|
|
}
|
|
|
|
function nonEmptyString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0
|
|
? value.trim()
|
|
: null;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
function errorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|