From caee37c6316cf2f9befe946d1a5af21a68358316 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:54:17 +0800 Subject: [PATCH 01/30] chore: init tool call styles branch From ef4e57ffddd730b5b2fa527df62ba14c65da44da Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:13:01 +0800 Subject: [PATCH 02/30] refactor(sdk): unify message types with pi-ai source of truth Replace hand-written message/content types in @multica/sdk with `import type` from @mariozechner/pi-ai and @mariozechner/pi-agent-core. This ensures compile-time correctness and eliminates type drift between backend and frontend (e.g. "tool_use" vs "toolCall", "tool_result" vs "toolResult"). - stream.ts: re-export TextContent, ThinkingContent, ToolCall, ImageContent from pi-ai; use AgentEvent from pi-agent-core - rpc.ts: AgentMessageItem = pi-ai Message (no more manual mirroring) - connection-store.ts: use SDK types instead of inline hand-written ones - ContentBlock now includes ImageContent to match backend reality Co-Authored-By: Claude Opus 4.5 --- packages/sdk/package.json | 2 + packages/sdk/src/actions/index.ts | 13 ++- packages/sdk/src/actions/rpc.ts | 35 ++------ packages/sdk/src/actions/stream.ts | 66 +++++++-------- packages/store/src/connection-store.ts | 111 ++++++++++++++++++------- pnpm-lock.yaml | 96 ++++++++++++++++++++- 6 files changed, 224 insertions(+), 99 deletions(-) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 7586e1d5..3eef6b24 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -17,6 +17,8 @@ "uuid": "^13.0.0" }, "devDependencies": { + "@mariozechner/pi-agent-core": "^0.50.3", + "@mariozechner/pi-ai": "^0.50.3", "@types/uuid": "^11.0.0", "typescript": "^5.9.3" } diff --git a/packages/sdk/src/actions/index.ts b/packages/sdk/src/actions/index.ts index e73947a4..4a28bc4f 100644 --- a/packages/sdk/src/actions/index.ts +++ b/packages/sdk/src/actions/index.ts @@ -33,8 +33,15 @@ export { export { StreamAction, type StreamPayload, - type StreamEvent, - type StreamMessageEvent, - type StreamToolEvent, + type AgentEvent, + type ContentBlock, + type TextContent, + type ThinkingContent, + type ToolCall, + type ImageContent, + // Backward-compatible aliases + type TextBlock, + type ThinkingBlock, + type ToolCallBlock, extractTextFromEvent, } from "./stream"; diff --git a/packages/sdk/src/actions/rpc.ts b/packages/sdk/src/actions/rpc.ts index 1f49f1d1..32c605de 100644 --- a/packages/sdk/src/actions/rpc.ts +++ b/packages/sdk/src/actions/rpc.ts @@ -1,5 +1,7 @@ /** RPC Actions - 请求/响应模式 */ +import type { Message } from "@mariozechner/pi-ai"; + export const RequestAction = "request" as const; export const ResponseAction = "response" as const; @@ -65,34 +67,11 @@ export interface GetAgentMessagesParams { limit?: number; } -/** Content block types from the agent engine */ -export interface TextContentBlock { - type: "text"; - text: string; -} - -export interface ThinkingContentBlock { - type: "thinking"; - thinking: string; -} - -export interface ToolCallBlock { - type: "tool_use"; - id: string; - name: string; - input: unknown; -} - -export interface ImageContentBlock { - type: "image"; - url: string; -} - -/** Agent message returned by getAgentMessages (mirrors pi-ai Message) */ -export type AgentMessageItem = - | { role: "user"; content: string | (TextContentBlock | ImageContentBlock)[]; timestamp: number } - | { role: "assistant"; content: (TextContentBlock | ThinkingContentBlock | ToolCallBlock)[]; timestamp: number } - | { role: "tool_result"; toolCallId: string; content: (TextContentBlock | ImageContentBlock)[]; isError: boolean; timestamp: number } +/** + * Agent message returned by getAgentMessages. + * This is pi-ai's Message type — the backend returns it as-is from SessionManager.loadMessages(). + */ +export type AgentMessageItem = Message; /** getAgentMessages - response payload */ export interface GetAgentMessagesResult { diff --git a/packages/sdk/src/actions/stream.ts b/packages/sdk/src/actions/stream.ts index 51329040..8babb7c0 100644 --- a/packages/sdk/src/actions/stream.ts +++ b/packages/sdk/src/actions/stream.ts @@ -1,49 +1,47 @@ -/** Stream Action - 流式消息传输 */ +/** Stream Action */ export const StreamAction = "stream" as const; +// --- Content block types (re-exported from pi-ai, the single source of truth) --- + +import type { + TextContent, + ThinkingContent, + ToolCall, + ImageContent, +} from "@mariozechner/pi-ai"; +import type { AgentEvent } from "@mariozechner/pi-agent-core"; + +export type { TextContent, ThinkingContent, ToolCall, ImageContent }; +export type { AgentEvent }; + +/** Backward-compatible aliases */ +export type TextBlock = TextContent; +export type ThinkingBlock = ThinkingContent; +export type ToolCallBlock = ToolCall; +export type ContentBlock = TextContent | ThinkingContent | ToolCall | ImageContent; + +// --- Stream event types --- + /** - * AgentEvent types forwarded by the Hub to frontend clients. - * These mirror the subset of AgentEvent from @mariozechner/pi-agent-core - * that the Hub forwards (filtered at the Hub layer). + * Hub forwards AgentEvent from pi-agent-core as-is. + * StreamPayload wraps it with routing metadata. */ -export interface StreamMessageEvent { - type: "message_start" | "message_update" | "message_end"; - message: { - id?: string; - role: string; - content?: Array<{ type: string; text?: string }>; - }; - assistantMessageEvent?: unknown; -} - -export interface StreamToolEvent { - type: "tool_execution_start" | "tool_execution_end"; - toolCallId: string; - toolName: string; - args?: unknown; - result?: unknown; - isError?: boolean; -} - -export type StreamEvent = StreamMessageEvent | StreamToolEvent; - -/** 流消息 payload — wraps a raw AgentEvent with stream/agent identifiers */ export interface StreamPayload { - /** 流 ID,关联同一个流的所有消息 */ streamId: string; - /** 所属 agent ID */ agentId: string; - /** Raw agent event from the engine */ - event: StreamEvent; + event: AgentEvent; } -/** Extract plain text from an AgentMessage content array */ -export function extractTextFromEvent(event: StreamMessageEvent): string { - const content = event.message?.content; +/** Extract plain text from an AgentEvent that carries a message */ +export function extractTextFromEvent(event: AgentEvent): string { + if (!("message" in event)) return ""; + const msg = event.message; + if (!msg || !("content" in msg)) return ""; + const content = msg.content; if (!Array.isArray(content)) return ""; return content - .filter((c) => c.type === "text") + .filter((c): c is TextContent => c.type === "text") .map((c) => c.text ?? "") .join(""); } diff --git a/packages/store/src/connection-store.ts b/packages/store/src/connection-store.ts index 19dde590..998a34f9 100644 --- a/packages/store/src/connection-store.ts +++ b/packages/store/src/connection-store.ts @@ -19,13 +19,14 @@ import { v7 as uuidv7 } from "uuid" import { GatewayClient, StreamAction, - extractTextFromEvent, type ConnectionState, type SendErrorResponse, type StreamPayload, - type StreamMessageEvent, + type AgentEvent, + type GetAgentMessagesResult, + type ContentBlock, } from "@multica/sdk" -import { useMessagesStore } from "./messages" +import { useMessagesStore, type Message } from "./messages" import { clearConnection, type ConnectionInfo } from "./connection" interface ConnectionStoreState { @@ -104,23 +105,40 @@ function createClient( switch (event.type) { case "message_start": { store.startStream(payload.streamId, payload.agentId) - const text = extractTextFromEvent(event as StreamMessageEvent) - if (text) store.appendStream(payload.streamId, text) + const content = extractContent(event) + if (content.length) store.appendStream(payload.streamId, content) break } case "message_update": { - const text = extractTextFromEvent(event as StreamMessageEvent) - store.appendStream(payload.streamId, text) + const content = extractContent(event) + store.appendStream(payload.streamId, content) break } case "message_end": { - const text = extractTextFromEvent(event as StreamMessageEvent) - store.endStream(payload.streamId, text) + const content = extractContent(event) + const stopReason = "message" in event + ? (event.message as { stopReason?: string })?.stopReason + : undefined + store.endStream(payload.streamId, content, stopReason) break } - case "tool_execution_start": - case "tool_execution_end": + case "tool_execution_start": { + store.startToolExecution( + payload.agentId, + event.toolCallId, + event.toolName, + event.args, + ) break + } + case "tool_execution_end": { + store.endToolExecution( + event.toolCallId, + event.result, + event.isError, + ) + break + } } return } @@ -140,20 +158,41 @@ async function fetchHistory(state: ConnectionStoreState): Promise { if (!client || !hubId || !agentId) return try { - const result = await client.request<{ - messages: Array<{ role: string; content: unknown }> - total: number - }>(hubId, "getAgentMessages", { agentId, limit: 200 }) + const result = await client.request( + hubId, "getAgentMessages", { agentId, limit: 200 }, + ) - const messages = result.messages - .filter((m) => m.role === "user" || m.role === "assistant") - .map((m) => ({ - id: uuidv7(), - role: m.role as "user" | "assistant", - content: extractText(m.content), - agentId: agentId, - })) - .filter((m) => m.content.length > 0) + // Mirror the backend message array directly + const messages: Message[] = [] + for (const m of result.messages) { + if (m.role === "user") { + messages.push({ + id: uuidv7(), + role: "user", + content: toContentBlocks(m.content), + agentId, + }) + } else if (m.role === "assistant") { + messages.push({ + id: uuidv7(), + role: "assistant", + content: toContentBlocks(m.content), + agentId, + stopReason: m.stopReason, + }) + } else if (m.role === "toolResult") { + messages.push({ + id: uuidv7(), + role: "toolResult", + content: toContentBlocks(m.content), + agentId, + toolCallId: m.toolCallId, + toolName: m.toolName, + toolStatus: m.isError ? "error" : "success", + isError: m.isError, + }) + } + } if (messages.length > 0) { useMessagesStore.getState().loadMessages(messages) @@ -163,14 +202,22 @@ async function fetchHistory(state: ConnectionStoreState): Promise { } } -/** Extract plain text from AgentMessage content (string or content block array) */ -function extractText(content: unknown): string { - if (typeof content === "string") return content - if (!Array.isArray(content)) return "" - return content - .filter((c: { type?: string }) => c.type === "text") - .map((c: { text?: string }) => c.text ?? "") - .join("") +/** Convert raw backend content (string or block array) to ContentBlock[] */ +function toContentBlocks(content: string | ContentBlock[]): ContentBlock[] { + if (typeof content === "string") { + return content ? [{ type: "text", text: content }] : [] + } + if (Array.isArray(content)) return content + return [] +} + +/** Extract content blocks from an AgentEvent that carries a message */ +function extractContent(event: AgentEvent): ContentBlock[] { + if (!("message" in event)) return [] + const msg = event.message + if (!msg || !("content" in msg)) return [] + const content = msg.content + return Array.isArray(content) ? content as ContentBlock[] : [] } export const useConnectionStore = create()( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fd147e6..0eeabcd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -427,6 +427,12 @@ importers: specifier: ^13.0.0 version: 13.0.0 devDependencies: + '@mariozechner/pi-agent-core': + specifier: ^0.50.3 + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) + '@mariozechner/pi-ai': + specifier: ^0.50.3 + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@types/uuid': specifier: ^11.0.0 version: 11.0.0 @@ -9094,6 +9100,12 @@ snapshots: optionalDependencies: zod: 3.25.76 + '@anthropic-ai/sdk@0.71.2(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.3.6 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -10876,6 +10888,17 @@ snapshots: - supports-color - utf-8-validate + '@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))': + dependencies: + google-auth-library: 10.5.0 + ws: 8.18.3 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@4.3.6) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@hono/node-server@1.19.9(hono@4.11.7)': dependencies: hono: 4.11.7 @@ -11218,6 +11241,19 @@ snapshots: - ws - zod + '@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': + dependencies: + '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) + '@mariozechner/pi-tui': 0.50.3 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': dependencies: '@anthropic-ai/sdk': 0.71.2(zod@3.25.76) @@ -11242,6 +11278,30 @@ snapshots: - ws - zod + '@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': + dependencies: + '@anthropic-ai/sdk': 0.71.2(zod@4.3.6) + '@aws-sdk/client-bedrock-runtime': 3.978.0 + '@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6)) + '@mistralai/mistralai': 1.10.0 + '@sinclair/typebox': 0.34.48 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + chalk: 5.6.2 + openai: 6.10.0(ws@8.18.3)(zod@4.3.6) + partial-json: 0.1.7 + proxy-agent: 6.5.0 + undici: 7.19.2 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': dependencies: '@mariozechner/clipboard': 0.3.0 @@ -11305,6 +11365,29 @@ snapshots: - hono - supports-color + '@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.11.7) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 7.5.1(express@5.2.1) + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - hono + - supports-color + optional: true + '@mozilla/readability@0.6.0': {} '@mswjs/interceptors@0.40.0': @@ -14376,7 +14459,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -14407,7 +14490,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -17124,6 +17207,11 @@ snapshots: ws: 8.18.3 zod: 3.25.76 + openai@6.10.0(ws@8.18.3)(zod@4.3.6): + optionalDependencies: + ws: 8.18.3 + zod: 4.3.6 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -19414,6 +19502,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 From 583242baba89f21a6cda1ce1f2c7147e2a9d7894 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:30:37 +0800 Subject: [PATCH 03/30] feat(store,ui): add tool execution lifecycle and ContentBlock rendering - Message.content is now ContentBlock[] (was string), supporting text, thinking, toolCall, and image blocks - Add toolResult role with toolCallId, toolName, toolStatus, isError - Add startToolExecution/endToolExecution to MessagesStore - MessageList renders toolResult messages via ToolCallItem - Extract text from ContentBlock[] for markdown rendering Co-Authored-By: Claude Opus 4.5 --- packages/store/src/index.ts | 2 +- packages/store/src/messages.ts | 117 +++++++++++++++----- packages/ui/src/components/message-list.tsx | 40 ++++++- 3 files changed, 124 insertions(+), 35 deletions(-) diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index b4e5a097..f2ee2670 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -2,6 +2,6 @@ export { useConnectionStore } from "./connection-store" export type { ConnectionStore } from "./connection-store" export { useAutoConnect } from "./use-auto-connect" export { useMessagesStore } from "./messages" -export type { Message, MessagesStore, SendContext } from "./messages" +export type { Message, MessagesStore, SendContext, ToolStatus } from "./messages" export { parseConnectionCode, saveConnection, loadConnection, clearConnection } from "./connection" export type { ConnectionInfo } from "./connection" diff --git a/packages/store/src/messages.ts b/packages/store/src/messages.ts index 0ced06aa..61827471 100644 --- a/packages/store/src/messages.ts +++ b/packages/store/src/messages.ts @@ -1,28 +1,33 @@ /** - * Messages Store - manages chat messages and streaming state for the current Agent + * Messages Store - manages chat messages and streaming state * - * Responsibilities: - * 1. Store current Agent's chat messages (replaced on Agent switch, not accumulated) - * 2. Manage streaming state (intermediate state while AI replies arrive in chunks) - * 3. Provide sendMessage() as the single entry point for sending messages + * Data model mirrors the backend (pi-ai / pi-agent-core) exactly: + * - UserMessage: { role: "user", content: ContentBlock[] } + * - AssistantMessage: { role: "assistant", content: ContentBlock[] } + * - ToolResultMessage: { role: "toolResult", toolCallId, toolName, content, isError } * - * Send flow: - * user input → sendMessage(text) - * → addUserMessage() immediately adds to local state (optimistic update) - * → ConnectionStore.send() sends to Gateway → Hub → Agent - * - * Receive flow (driven by ConnectionStore's onMessage callback): - * Streaming: startStream → appendStream (repeated) → endStream - * Non-streaming: addAssistantMessage (one-shot) + * Streaming simply updates the content of the current assistant message in-place. + * Tool execution events (start/end) create / update toolResult messages. */ import { create } from "zustand" import { v7 as uuidv7 } from "uuid" +import type { ContentBlock } from "@multica/sdk" + +export type ToolStatus = "running" | "success" | "error" | "interrupted" export interface Message { id: string - role: "user" | "assistant" - content: string + role: "user" | "assistant" | "toolResult" + content: ContentBlock[] agentId: string + // AssistantMessage metadata + stopReason?: string + // ToolResult fields (only when role === "toolResult") + toolCallId?: string + toolName?: string + toolArgs?: Record + toolStatus?: ToolStatus + isError?: boolean } /** Parameters needed to route a message through the gateway */ @@ -41,13 +46,16 @@ interface MessagesActions { sendMessage: (text: string, ctx: SendContext) => void addUserMessage: (content: string, agentId: string) => void addAssistantMessage: (content: string, agentId: string) => void - updateMessage: (id: string, content: string) => void - // Replace all messages (for Agent switch or loading history) + updateMessage: (id: string, content: ContentBlock[]) => void loadMessages: (msgs: Message[]) => void clearMessages: () => void + // Streaming startStream: (streamId: string, agentId: string) => void - appendStream: (streamId: string, content: string) => void - endStream: (streamId: string, content: string) => void + appendStream: (streamId: string, content: ContentBlock[]) => void + endStream: (streamId: string, content: ContentBlock[], stopReason?: string) => void + // Tool execution lifecycle + startToolExecution: (agentId: string, toolCallId: string, toolName: string, args?: unknown) => void + endToolExecution: (toolCallId: string, result?: unknown, isError?: boolean) => void } export type MessagesStore = MessagesState & MessagesActions @@ -56,7 +64,6 @@ export const useMessagesStore = create()((set, get) => ({ messages: [], streamingIds: new Set(), - // Single entry point for sending: optimistic local add, then send via WebSocket sendMessage: (text, ctx) => { get().addUserMessage(text, ctx.agentId) ctx.send(ctx.hubId, "message", { agentId: ctx.agentId, content: text }) @@ -64,13 +71,23 @@ export const useMessagesStore = create()((set, get) => ({ addUserMessage: (content, agentId) => { set((s) => ({ - messages: [...s.messages, { id: uuidv7(), role: "user", content, agentId }], + messages: [...s.messages, { + id: uuidv7(), + role: "user", + content: [{ type: "text" as const, text: content }], + agentId, + }], })) }, addAssistantMessage: (content, agentId) => { set((s) => ({ - messages: [...s.messages, { id: uuidv7(), role: "assistant", content, agentId }], + messages: [...s.messages, { + id: uuidv7(), + role: "assistant", + content: [{ type: "text" as const, text: content }], + agentId, + }], })) }, @@ -80,7 +97,6 @@ export const useMessagesStore = create()((set, get) => ({ })) }, - // Replace all messages (for Agent switch or loading history) loadMessages: (msgs) => { set({ messages: msgs }) }, @@ -89,35 +105,76 @@ export const useMessagesStore = create()((set, get) => ({ set({ messages: [], streamingIds: new Set() }) }, - // === The following three methods are called by ConnectionStore's onMessage callback === - // Stream start: create an empty placeholder message and mark as streaming + // --- Streaming: build assistant message incrementally --- + startStream: (streamId, agentId) => { set((s) => { const ids = new Set(s.streamingIds) ids.add(streamId) return { - messages: [...s.messages, { id: streamId, role: "assistant" as const, content: "", agentId }], + messages: [...s.messages, { id: streamId, role: "assistant" as const, content: [], agentId }], streamingIds: ids, } }) }, - // Stream update: replace message content (each update carries the full accumulated text) + // Replace the entire content array with the latest partial snapshot appendStream: (streamId, content) => { set((s) => ({ messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)), })) }, - // Stream end: write final content, remove streaming marker - endStream: (streamId, content) => { + endStream: (streamId, content, stopReason) => { set((s) => { const ids = new Set(s.streamingIds) ids.delete(streamId) return { - messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)), + messages: s.messages.map((m) => { + if (m.id === streamId) return { ...m, content, stopReason } + // Interrupt any still-running tool executions + if (m.role === "toolResult" && m.toolStatus === "running") { + return { ...m, toolStatus: "interrupted" as ToolStatus } + } + return m + }), streamingIds: ids, } }) }, + + // --- Tool execution: create / update toolResult messages --- + + startToolExecution: (agentId, toolCallId, toolName, args) => { + set((s) => ({ + messages: [...s.messages, { + id: uuidv7(), + role: "toolResult" as const, + content: [], + agentId, + toolCallId, + toolName, + toolArgs: args as Record | undefined, + toolStatus: "running" as ToolStatus, + isError: false, + }], + })) + }, + + endToolExecution: (toolCallId, result, isError) => { + set((s) => ({ + messages: s.messages.map((m) => + m.role === "toolResult" && m.toolCallId === toolCallId + ? { + ...m, + toolStatus: (isError ? "error" : "success") as ToolStatus, + isError: isError ?? false, + content: result != null + ? [{ type: "text" as const, text: typeof result === "string" ? result : JSON.stringify(result) }] + : [], + } + : m + ), + })) + }, })) diff --git a/packages/ui/src/components/message-list.tsx b/packages/ui/src/components/message-list.tsx index 937230e6..b372d96f 100644 --- a/packages/ui/src/components/message-list.tsx +++ b/packages/ui/src/components/message-list.tsx @@ -2,8 +2,23 @@ import { MemoizedMarkdown } from "@multica/ui/components/markdown"; import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown"; +import { ToolCallItem } from "@multica/ui/components/tool-call-item"; import { cn } from "@multica/ui/lib/utils"; import type { Message } from "@multica/store"; +import type { ContentBlock } from "@multica/sdk"; + +/** Extract plain text from ContentBlock[] */ +function getTextContent(blocks: ContentBlock[]): string { + return blocks + .filter((b): b is { type: "text"; text: string } => b.type === "text") + .map((b) => b.text) + .join("") +} + +/** Check if content has any toolCall blocks */ +function getToolCalls(blocks: ContentBlock[]) { + return blocks.filter((b): b is Extract => b.type === "toolCall") +} interface MessageListProps { messages: Message[] @@ -12,9 +27,26 @@ interface MessageListProps { export function MessageList({ messages, streamingIds }: MessageListProps) { return ( -
+
{messages.map((msg) => { + // ToolResult messages → render as tool execution item + if (msg.role === "toolResult") { + return + } + + const text = getTextContent(msg.content) + const toolCalls = msg.role === "assistant" ? getToolCalls(msg.content) : [] const isStreaming = streamingIds.has(msg.id) + + // Skip empty assistant messages that only contain toolCalls (no text) + // The toolCalls are visible via the subsequent toolResult entries + if (msg.role === "assistant" && !text && toolCalls.length > 0 && !isStreaming) { + return null + } + + // Skip completely empty messages + if (!text && !isStreaming) return null + return (
{isStreaming ? ( - + ) : ( - {msg.content} + {text} )}
From 703c4686d985a415d839717d7c192be11eb14fb9 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:30:46 +0800 Subject: [PATCH 04/30] feat(ui): improve tool-call-item with status dots, smart subtitles, and accessibility - Replace hugeicons status icons with CSS colored dots + glow-pulse animation for running state (honors prefers-reduced-motion) - Add smart subtitles from toolArgs: file basename, command preview, search pattern, URL hostname - Add right-aligned stats: line count, match count, file count - Add hover chevron for expand/collapse affordance - Fix accessibility: aria-label, aria-expanded, focus-visible ring - Store toolArgs in Message (was received but discarded) - Extract toolArgs from assistant ToolCall blocks during fetchHistory - Add --tool-running/success/error CSS variables with dark mode support Co-Authored-By: Claude Opus 4.5 --- packages/store/src/connection-store.ts | 14 ++ packages/ui/src/components/tool-call-item.tsx | 228 ++++++++++++++++++ packages/ui/src/styles/globals.css | 12 + 3 files changed, 254 insertions(+) create mode 100644 packages/ui/src/components/tool-call-item.tsx diff --git a/packages/store/src/connection-store.ts b/packages/store/src/connection-store.ts index 998a34f9..ea2407c2 100644 --- a/packages/store/src/connection-store.ts +++ b/packages/store/src/connection-store.ts @@ -162,6 +162,18 @@ async function fetchHistory(state: ConnectionStoreState): Promise { hubId, "getAgentMessages", { agentId, limit: 200 }, ) + // Build a lookup map: toolCallId → { name, arguments } from assistant ToolCall blocks + const toolCallArgsMap = new Map }>() + for (const m of result.messages) { + if (m.role === "assistant") { + for (const block of m.content) { + if (block.type === "toolCall") { + toolCallArgsMap.set(block.id, { name: block.name, args: block.arguments }) + } + } + } + } + // Mirror the backend message array directly const messages: Message[] = [] for (const m of result.messages) { @@ -181,6 +193,7 @@ async function fetchHistory(state: ConnectionStoreState): Promise { stopReason: m.stopReason, }) } else if (m.role === "toolResult") { + const callInfo = toolCallArgsMap.get(m.toolCallId) messages.push({ id: uuidv7(), role: "toolResult", @@ -188,6 +201,7 @@ async function fetchHistory(state: ConnectionStoreState): Promise { agentId, toolCallId: m.toolCallId, toolName: m.toolName, + toolArgs: callInfo?.args, toolStatus: m.isError ? "error" : "success", isError: m.isError, }) diff --git a/packages/ui/src/components/tool-call-item.tsx b/packages/ui/src/components/tool-call-item.tsx new file mode 100644 index 00000000..eaf97d88 --- /dev/null +++ b/packages/ui/src/components/tool-call-item.tsx @@ -0,0 +1,228 @@ +"use client" + +import { useState } from "react" +import { HugeiconsIcon } from "@hugeicons/react" +import { + File01Icon, + FloppyDiskIcon, + FileEditIcon, + CommandLineIcon, + Search01Icon, + FolderOpenIcon, + GlobeIcon, + DatabaseIcon, + GitBranchIcon, + ArrowRight01Icon, +} from "@hugeicons/core-free-icons" +import { cn } from "@multica/ui/lib/utils" +import type { Message } from "@multica/store" +import type { ContentBlock } from "@multica/sdk" + +// --------------------------------------------------------------------------- +// Tool display config +// --------------------------------------------------------------------------- + +const TOOL_DISPLAY: Record = { + read: { label: "Read", icon: File01Icon }, + write: { label: "Write", icon: FloppyDiskIcon }, + edit: { label: "Edit", icon: FileEditIcon }, + exec: { label: "Exec", icon: CommandLineIcon }, + bash: { label: "Exec", icon: CommandLineIcon }, + process: { label: "Process", icon: CommandLineIcon }, + grep: { label: "Grep", icon: Search01Icon }, + find: { label: "Find", icon: Search01Icon }, + ls: { label: "ListDir", icon: FolderOpenIcon }, + glob: { label: "Glob", icon: Search01Icon }, + web_search: { label: "WebSearch", icon: GlobeIcon }, + web_fetch: { label: "WebFetch", icon: GlobeIcon }, + memory_get: { label: "MemoryGet", icon: DatabaseIcon }, + memory_set: { label: "MemorySet", icon: DatabaseIcon }, + memory_delete: { label: "MemoryDelete", icon: DatabaseIcon }, + memory_list: { label: "MemoryList", icon: DatabaseIcon }, + sessions_spawn: { label: "SpawnSession", icon: GitBranchIcon }, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Extract plain text from ContentBlock[] */ +function getResultText(blocks: ContentBlock[]): string { + return blocks + .filter((b): b is { type: "text"; text: string } => b.type === "text") + .map((b) => b.text) + .join("") +} + +/** Extract a short basename from a file path */ +function basename(path: string): string { + return path.split("/").pop() ?? path +} + +/** Smart subtitle based on tool type and args */ +function getSubtitle(toolName: string, args?: Record): string { + if (!args) return "" + switch (toolName) { + case "read": + case "write": + case "edit": + return args.path ? basename(String(args.path)) : "" + case "exec": + case "bash": + case "process": { + const cmd = String(args.command ?? args.cmd ?? "") + return cmd.length > 60 ? cmd.slice(0, 57) + "…" : cmd + } + case "grep": + case "find": + return args.pattern ? String(args.pattern) : "" + case "glob": + return args.pattern ? String(args.pattern) : "" + case "web_search": + return args.query ? String(args.query) : "" + case "web_fetch": + try { return new URL(String(args.url)).hostname } catch { return String(args.url ?? "") } + default: + return "" + } +} + +/** Running-state label per tool */ +const RUNNING_LABELS: Record = { + read: "reading…", + write: "writing…", + edit: "editing…", + exec: "running…", + bash: "running…", + process: "running…", + grep: "searching…", + find: "searching…", + glob: "searching…", + web_search: "searching…", + web_fetch: "fetching…", +} + +/** Stats derived from tool result content */ +function getStats(toolName: string, toolStatus: string, resultText: string): string { + if (toolStatus === "running") return RUNNING_LABELS[toolName] ?? "working…" + if (toolStatus === "error" || toolStatus === "interrupted" || !resultText) return "" + + switch (toolName) { + case "read": { + const lines = resultText.split("\n").length + return `${lines} lines` + } + case "grep": { + const matches = resultText.split("\n").filter((l) => l.trim()).length + return matches > 0 ? `${matches} matches` : "" + } + case "glob": + case "find": { + const files = resultText.split("\n").filter((l) => l.trim()).length + return files > 0 ? `${files} files` : "" + } + default: + return "" + } +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function ToolCallItem({ message }: { message: Message }) { + const [expanded, setExpanded] = useState(false) + const { toolName = "", toolStatus = "running", toolArgs, content } = message + + const display = TOOL_DISPLAY[toolName] ?? { label: toolName, icon: CommandLineIcon } + const isFinished = toolStatus !== "running" + const resultText = getResultText(content) + const hasDetails = isFinished && !!resultText + const subtitle = getSubtitle(toolName, toolArgs) + const stats = getStats(toolName, toolStatus, resultText) + + return ( +
+ + + {/* Expanded result */} + {expanded && resultText && ( +
+ {resultText} +
+ )} +
+ ) +} diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 7e57c362..cd74a06b 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -86,6 +86,9 @@ --scrollbar-thumb: oklch(0.82 0.003 286); --scrollbar-thumb-hover: oklch(0.705 0.015 286.067); --scrollbar-track: transparent; + --tool-running: oklch(0.6 0.18 250); + --tool-success: oklch(0.72 0.12 145); + --tool-error: oklch(0.65 0.2 25); } .dark { @@ -123,6 +126,9 @@ --scrollbar-thumb: oklch(1 0 0 / 15%); --scrollbar-thumb-hover: oklch(1 0 0 / 30%); --scrollbar-track: transparent; + --tool-running: oklch(0.65 0.2 250); + --tool-success: oklch(0.65 0.15 145); + --tool-error: oklch(0.7 0.2 22); } /* Shiki dual themes: CSS-only light/dark switching via CSS variables */ @@ -213,3 +219,9 @@ opacity: 0.7; } } + +/* Tool status: running glow pulse */ +@keyframes glow-pulse { + 0%, 100% { box-shadow: 0 0 0 0 var(--tool-running); } + 50% { box-shadow: 0 0 0 3px oklch(0.6 0.2 250 / 0); } +} From cfd46ee602a5605f16ece9bd46d9a8c6d9ff847d Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:12:24 +0800 Subject: [PATCH 05/30] refactor(sdk,store): clean up SDK exports and scope tool interruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove backward-compatible aliases (TextBlock, ThinkingBlock, ToolCallBlock) and extractTextFromEvent from SDK — unused after prior refactors - Add explicit ContentBlock doc comment explaining the wider union tradeoff - Scope endStream tool interruption to the same agentId (prevents cross-agent interference in multi-agent scenarios) - Handle tool_execution_update event (no-op for now, avoids unhandled case) Co-Authored-By: Claude Opus 4.5 --- packages/sdk/src/actions/index.ts | 5 ----- packages/sdk/src/actions/stream.ts | 25 ++++++++----------------- packages/store/src/connection-store.ts | 3 +++ packages/store/src/messages.ts | 7 +++++-- 4 files changed, 16 insertions(+), 24 deletions(-) diff --git a/packages/sdk/src/actions/index.ts b/packages/sdk/src/actions/index.ts index 4a28bc4f..cdbbe9ce 100644 --- a/packages/sdk/src/actions/index.ts +++ b/packages/sdk/src/actions/index.ts @@ -39,9 +39,4 @@ export { type ThinkingContent, type ToolCall, type ImageContent, - // Backward-compatible aliases - type TextBlock, - type ThinkingBlock, - type ToolCallBlock, - extractTextFromEvent, } from "./stream"; diff --git a/packages/sdk/src/actions/stream.ts b/packages/sdk/src/actions/stream.ts index 8babb7c0..8437029e 100644 --- a/packages/sdk/src/actions/stream.ts +++ b/packages/sdk/src/actions/stream.ts @@ -15,10 +15,14 @@ import type { AgentEvent } from "@mariozechner/pi-agent-core"; export type { TextContent, ThinkingContent, ToolCall, ImageContent }; export type { AgentEvent }; -/** Backward-compatible aliases */ -export type TextBlock = TextContent; -export type ThinkingBlock = ThinkingContent; -export type ToolCallBlock = ToolCall; +/** + * Convenience union of all content block types across message roles. + * + * NOTE: This is a deliberate simplification. The backend uses narrower unions + * per role (e.g. AssistantMessage.content excludes ImageContent, UserMessage + * excludes ThinkingContent/ToolCall). We accept the wider union on the frontend + * for simpler handling — the backend already guarantees correctness. + */ export type ContentBlock = TextContent | ThinkingContent | ToolCall | ImageContent; // --- Stream event types --- @@ -32,16 +36,3 @@ export interface StreamPayload { agentId: string; event: AgentEvent; } - -/** Extract plain text from an AgentEvent that carries a message */ -export function extractTextFromEvent(event: AgentEvent): string { - if (!("message" in event)) return ""; - const msg = event.message; - if (!msg || !("content" in msg)) return ""; - const content = msg.content; - if (!Array.isArray(content)) return ""; - return content - .filter((c): c is TextContent => c.type === "text") - .map((c) => c.text ?? "") - .join(""); -} diff --git a/packages/store/src/connection-store.ts b/packages/store/src/connection-store.ts index ea2407c2..b2951b38 100644 --- a/packages/store/src/connection-store.ts +++ b/packages/store/src/connection-store.ts @@ -139,6 +139,9 @@ function createClient( ) break } + case "tool_execution_update": + // Partial results — not rendered yet, ignored for now + break } return } diff --git a/packages/store/src/messages.ts b/packages/store/src/messages.ts index 61827471..45555dd6 100644 --- a/packages/store/src/messages.ts +++ b/packages/store/src/messages.ts @@ -129,11 +129,14 @@ export const useMessagesStore = create()((set, get) => ({ set((s) => { const ids = new Set(s.streamingIds) ids.delete(streamId) + // Find the agentId of the stream being ended to scope tool interruption + const streamMsg = s.messages.find((m) => m.id === streamId) + const streamAgentId = streamMsg?.agentId return { messages: s.messages.map((m) => { if (m.id === streamId) return { ...m, content, stopReason } - // Interrupt any still-running tool executions - if (m.role === "toolResult" && m.toolStatus === "running") { + // Interrupt running tool executions belonging to the same agent + if (m.role === "toolResult" && m.toolStatus === "running" && m.agentId === streamAgentId) { return { ...m, toolStatus: "interrupted" as ToolStatus } } return m From 1f7951df1b2dff6666adcfde1d8664d593fe0667 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:12:34 +0800 Subject: [PATCH 06/30] fix(ui): render tool calls immediately from assistant toolCall blocks Tools were only visible after tool_execution_end because the UI relied solely on toolResult messages (created at tool_execution_start, ~8ms before end). Now MessageList detects toolCall blocks in the streaming assistant message and renders them as "running" ToolCallItems immediately. Once a real toolResult message arrives, the synthetic one is replaced. - Add resolvedToolCallIds set to deduplicate assistant vs toolResult renders - Extract getTextContent to shared utils to avoid duplication - Wrap MessageList and ToolCallItem in memo for performance - Add accessible region/tabIndex to expanded result panel Co-Authored-By: Claude Opus 4.5 --- packages/ui/src/components/message-list.tsx | 104 +++++++++++------- packages/ui/src/components/tool-call-item.tsx | 26 ++--- packages/ui/src/lib/utils.ts | 9 ++ 3 files changed, 85 insertions(+), 54 deletions(-) diff --git a/packages/ui/src/components/message-list.tsx b/packages/ui/src/components/message-list.tsx index b372d96f..2afc246a 100644 --- a/packages/ui/src/components/message-list.tsx +++ b/packages/ui/src/components/message-list.tsx @@ -1,23 +1,30 @@ "use client"; +import { memo, useMemo } from "react"; import { MemoizedMarkdown } from "@multica/ui/components/markdown"; import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown"; import { ToolCallItem } from "@multica/ui/components/tool-call-item"; -import { cn } from "@multica/ui/lib/utils"; +import { cn, getTextContent } from "@multica/ui/lib/utils"; import type { Message } from "@multica/store"; -import type { ContentBlock } from "@multica/sdk"; +import type { ContentBlock, ToolCall } from "@multica/sdk"; -/** Extract plain text from ContentBlock[] */ -function getTextContent(blocks: ContentBlock[]): string { - return blocks - .filter((b): b is { type: "text"; text: string } => b.type === "text") - .map((b) => b.text) - .join("") +/** Extract toolCall blocks from content */ +function getToolCalls(blocks: ContentBlock[]): ToolCall[] { + return blocks.filter((b): b is ToolCall => b.type === "toolCall") } -/** Check if content has any toolCall blocks */ -function getToolCalls(blocks: ContentBlock[]) { - return blocks.filter((b): b is Extract => b.type === "toolCall") +/** Build a synthetic "running" toolResult Message from a ToolCall block */ +function toRunningMessage(tc: ToolCall, agentId: string): Message { + return { + id: tc.id, + role: "toolResult", + content: [], + agentId, + toolCallId: tc.id, + toolName: tc.name, + toolArgs: tc.arguments, + toolStatus: "running", + } } interface MessageListProps { @@ -25,7 +32,19 @@ interface MessageListProps { streamingIds: Set } -export function MessageList({ messages, streamingIds }: MessageListProps) { +export const MessageList = memo(function MessageList({ messages, streamingIds }: MessageListProps) { + // Build a set of toolCallIds that already have a toolResult message, + // so we don't render duplicate items from the assistant's toolCall blocks + const resolvedToolCallIds = useMemo(() => { + const ids = new Set() + for (const msg of messages) { + if (msg.role === "toolResult" && msg.toolCallId) { + ids.add(msg.toolCallId) + } + } + return ids + }, [messages]) + return (
{messages.map((msg) => { @@ -38,39 +57,46 @@ export function MessageList({ messages, streamingIds }: MessageListProps) { const toolCalls = msg.role === "assistant" ? getToolCalls(msg.content) : [] const isStreaming = streamingIds.has(msg.id) - // Skip empty assistant messages that only contain toolCalls (no text) - // The toolCalls are visible via the subsequent toolResult entries - if (msg.role === "assistant" && !text && toolCalls.length > 0 && !isStreaming) { - return null - } + // Find toolCall blocks that don't have a toolResult message yet — + // these are tools the LLM decided to call but haven't started executing + const unresolvedToolCalls = toolCalls.filter((tc) => !resolvedToolCallIds.has(tc.id)) - // Skip completely empty messages - if (!text && !isStreaming) return null + // Skip completely empty messages (no text, no unresolved tools, not streaming) + if (!text && unresolvedToolCalls.length === 0 && !isStreaming) return null return ( -
+ {/* Render text content (if any) */} + {(text || isStreaming) && ( +
+
+ {isStreaming ? ( + + ) : ( + + {text} + + )} +
+
)} - > -
- {isStreaming ? ( - - ) : ( - - {text} - - )} -
+ + {/* Render unresolved toolCall blocks as "running" tool items */} + {unresolvedToolCalls.map((tc) => ( + + ))}
) })}
) -} +}) diff --git a/packages/ui/src/components/tool-call-item.tsx b/packages/ui/src/components/tool-call-item.tsx index eaf97d88..e8374cc8 100644 --- a/packages/ui/src/components/tool-call-item.tsx +++ b/packages/ui/src/components/tool-call-item.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { memo, useState } from "react" import { HugeiconsIcon } from "@hugeicons/react" import { File01Icon, @@ -14,9 +14,8 @@ import { GitBranchIcon, ArrowRight01Icon, } from "@hugeicons/core-free-icons" -import { cn } from "@multica/ui/lib/utils" +import { cn, getTextContent } from "@multica/ui/lib/utils" import type { Message } from "@multica/store" -import type { ContentBlock } from "@multica/sdk" // --------------------------------------------------------------------------- // Tool display config @@ -46,14 +45,6 @@ const TOOL_DISPLAY: Record = // Helpers // --------------------------------------------------------------------------- -/** Extract plain text from ContentBlock[] */ -function getResultText(blocks: ContentBlock[]): string { - return blocks - .filter((b): b is { type: "text"; text: string } => b.type === "text") - .map((b) => b.text) - .join("") -} - /** Extract a short basename from a file path */ function basename(path: string): string { return path.split("/").pop() ?? path @@ -130,13 +121,13 @@ function getStats(toolName: string, toolStatus: string, resultText: string): str // Component // --------------------------------------------------------------------------- -export function ToolCallItem({ message }: { message: Message }) { +export const ToolCallItem = memo(function ToolCallItem({ message }: { message: Message }) { const [expanded, setExpanded] = useState(false) const { toolName = "", toolStatus = "running", toolArgs, content } = message const display = TOOL_DISPLAY[toolName] ?? { label: toolName, icon: CommandLineIcon } const isFinished = toolStatus !== "running" - const resultText = getResultText(content) + const resultText = getTextContent(content) const hasDetails = isFinished && !!resultText const subtitle = getSubtitle(toolName, toolArgs) const stats = getStats(toolName, toolStatus, resultText) @@ -219,10 +210,15 @@ export function ToolCallItem({ message }: { message: Message }) { {/* Expanded result */} {expanded && resultText && ( -
+
{resultText}
)}
) -} +}) diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts index bd0c391d..68b3498d 100644 --- a/packages/ui/src/lib/utils.ts +++ b/packages/ui/src/lib/utils.ts @@ -1,6 +1,15 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" +import type { ContentBlock } from "@multica/sdk" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** Extract concatenated plain text from a ContentBlock array */ +export function getTextContent(blocks: ContentBlock[]): string { + return blocks + .filter((b): b is { type: "text"; text: string } => b.type === "text") + .map((b) => b.text) + .join("") +} From d7eb0da49b894692e94bdab7562c1be9db2d6ea0 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 4 Feb 2026 18:25:06 +0800 Subject: [PATCH 07/30] feat(agent): add provider switching and OAuth credential support - Add getProviderInfo() and setProvider() methods to Agent class - Expose provider methods via AsyncAgent - Add setLlmProviderOAuthToken() for storing OAuth credentials - Extend ProviderConfig type with OAuth fields (oauthToken, oauthRefreshToken, oauthExpiresAt) Co-Authored-By: Claude Opus 4.5 --- src/agent/async-agent.ts | 15 +++++ src/agent/credentials.ts | 136 ++++++++++++++++++++++++++++++++++++++- src/agent/runner.ts | 70 +++++++++++++++++++- 3 files changed, 218 insertions(+), 3 deletions(-) diff --git a/src/agent/async-agent.ts b/src/agent/async-agent.ts index 68475555..4fdfb616 100644 --- a/src/agent/async-agent.ts +++ b/src/agent/async-agent.ts @@ -221,4 +221,19 @@ export class AsyncAgent { getMessages(): AgentMessage[] { return this.agent.getMessages(); } + + /** + * Get current provider and model information. + */ + getProviderInfo(): { provider: string; model: string | undefined } { + return this.agent.getProviderInfo(); + } + + /** + * Switch to a different provider and/or model. + * This updates the agent's model without recreating the session. + */ + setProvider(providerId: string, modelId?: string): { provider: string; model: string | undefined } { + return this.agent.setProvider(providerId, modelId); + } } diff --git a/src/agent/credentials.ts b/src/agent/credentials.ts index 5f4c7555..223798e6 100644 --- a/src/agent/credentials.ts +++ b/src/agent/credentials.ts @@ -1,11 +1,17 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; import { homedir } from "node:os"; import JSON5 from "json5"; import { DATA_DIR } from "../shared/paths.js"; type ProviderConfig = { + // API Key authentication apiKey?: string | undefined; + // OAuth authentication + oauthToken?: string | undefined; + oauthRefreshToken?: string | undefined; + oauthExpiresAt?: number | undefined; + // Common baseUrl?: string | undefined; model?: string | undefined; }; @@ -223,6 +229,132 @@ export class CredentialManager { this.skillsConfig = null; this.resolvedSkillsEnv = null; } + + /** + * Set the API key for a provider and save to credentials.json5. + * Creates the file if it doesn't exist. + */ + setLlmProviderApiKey(provider: string, apiKey: string): void { + const path = getCredentialsPath(); + + // Load existing config or create new one + let config: CredentialsConfig = { version: 1 }; + if (existsSync(path)) { + try { + const raw = readFileSync(path, "utf8"); + config = JSON5.parse(raw) as CredentialsConfig; + } catch { + // If parse fails, start fresh + config = { version: 1 }; + } + } + + // Ensure structure exists + if (!config.llm) { + config.llm = {}; + } + if (!config.llm.providers) { + config.llm.providers = {}; + } + + // Set or update the provider config + const existing = config.llm.providers[provider] ?? {}; + config.llm.providers[provider] = { + ...existing, + apiKey, + }; + + // Write back to file + mkdirSync(dirname(path), { recursive: true }); + const content = JSON.stringify(config, null, 2); + writeFileSync(path, content, "utf8"); + + // Reset cache so next read picks up the change + this.reset(); + } + + /** + * Set OAuth token for a provider and save to credentials.json5. + * Used for OAuth providers like claude-code and openai-codex. + */ + setLlmProviderOAuthToken( + provider: string, + token: string, + refreshToken?: string, + expiresAt?: number, + ): void { + const path = getCredentialsPath(); + + // Load existing config or create new one + let config: CredentialsConfig = { version: 1 }; + if (existsSync(path)) { + try { + const raw = readFileSync(path, "utf8"); + config = JSON5.parse(raw) as CredentialsConfig; + } catch { + config = { version: 1 }; + } + } + + // Ensure structure exists + if (!config.llm) { + config.llm = {}; + } + if (!config.llm.providers) { + config.llm.providers = {}; + } + + // Set or update the provider config + const existing = config.llm.providers[provider] ?? {}; + config.llm.providers[provider] = { + ...existing, + oauthToken: token, + oauthRefreshToken: refreshToken, + oauthExpiresAt: expiresAt, + }; + + // Write back to file + mkdirSync(dirname(path), { recursive: true }); + const content = JSON.stringify(config, null, 2); + writeFileSync(path, content, "utf8"); + + // Reset cache + this.reset(); + } + + /** + * Set the default LLM provider and save to credentials.json5. + */ + setDefaultLlmProvider(provider: string): void { + const path = getCredentialsPath(); + + // Load existing config or create new one + let config: CredentialsConfig = { version: 1 }; + if (existsSync(path)) { + try { + const raw = readFileSync(path, "utf8"); + config = JSON5.parse(raw) as CredentialsConfig; + } catch { + config = { version: 1 }; + } + } + + // Ensure structure exists + if (!config.llm) { + config.llm = {}; + } + + // Set default provider + config.llm.provider = provider; + + // Write back to file + mkdirSync(dirname(path), { recursive: true }); + const content = JSON.stringify(config, null, 2); + writeFileSync(path, content, "utf8"); + + // Reset cache + this.reset(); + } } export const credentialManager = new CredentialManager(); diff --git a/src/agent/runner.ts b/src/agent/runner.ts index f66ef159..98f233d9 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -9,6 +9,8 @@ import { resolveApiKeyForProvider, resolveBaseUrl, resolveModelId, + PROVIDER_ALIAS, + getDefaultModel, } from "./providers/index.js"; import { SessionManager } from "./session/session-manager.js"; import { ProfileManager } from "./profile/index.js"; @@ -82,7 +84,7 @@ export class Agent { private initialized = false; // Auth profile rotation state - private readonly resolvedProvider: string; + private resolvedProvider: string; private currentApiKey: string | undefined; private currentProfileId: string | undefined; private profileCandidates: string[]; @@ -598,6 +600,72 @@ export class Agent { this.profile?.updateStyle(style); } + /** + * Get current provider and model information. + */ + getProviderInfo(): { provider: string; model: string | undefined } { + return { + provider: this.resolvedProvider, + model: this.agent.state.model?.id, + }; + } + + /** + * Switch to a different provider and/or model. + * This updates the agent's model without recreating the session. + */ + setProvider(providerId: string, modelId?: string): { provider: string; model: string | undefined } { + // Resolve the actual provider (handle aliases like claude-code -> anthropic) + const actualProvider = PROVIDER_ALIAS[providerId] ?? providerId; + + // Resolve the model + const targetModel = modelId ?? getDefaultModel(providerId) ?? getDefaultModel(actualProvider); + const model = resolveModel({ provider: providerId, model: targetModel }); + + if (!model) { + throw new Error(`Failed to resolve model for provider: ${providerId}, model: ${targetModel}`); + } + + // Resolve API key for the new provider + // For OAuth providers (claude-code, openai-codex), we need to use the original providerId + // because OAuth credentials are resolved by the original provider name, not the alias + const resolved = resolveApiKeyForProvider(providerId); + if (resolved) { + this.currentApiKey = resolved.apiKey; + this.currentProfileId = resolved.profileId; + } else { + // Fallback: try with actual provider (for API key based providers) + this.currentApiKey = resolveApiKey(actualProvider); + this.currentProfileId = actualProvider; + } + + if (!this.currentApiKey) { + throw new Error(`No API key configured for provider: ${providerId}`); + } + + // Update the agent's model and API key + const baseUrl = resolveBaseUrl(actualProvider); + const modelWithBaseUrl = baseUrl ? { ...model, baseUrl } : model; + this.agent.setModel(modelWithBaseUrl); + + // Update internal state + this.resolvedProvider = providerId; + + // Update session metadata + this.session.saveMeta({ + provider: actualProvider, + model: model.id, + thinkingLevel: this.agent.state.thinkingLevel, + reasoningMode: this.reasoningMode, + contextWindowTokens: this.contextWindowGuard.tokens, + }); + + return { + provider: providerId, + model: model.id, + }; + } + /** * Build the full system prompt using the structured builder. * Combines profile content, tools, skills, and runtime info. From 48245be52df69281aec430f2372bcba82d017de5 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 4 Feb 2026 18:25:13 +0800 Subject: [PATCH 08/30] feat(desktop): add provider IPC handlers - Add provider.ts with handlers for list, current, set, saveApiKey, importOAuth - Import OAuth credentials from CLI tools (Claude Code, Codex) - Register provider handlers in IPC index - Expose provider API in preload.ts with TypeScript types Co-Authored-By: Claude Opus 4.5 --- apps/desktop/electron/electron-env.d.ts | 31 +++ apps/desktop/electron/ipc/index.ts | 3 + apps/desktop/electron/ipc/provider.ts | 312 ++++++++++++++++++++++++ apps/desktop/electron/preload.ts | 44 ++++ 4 files changed, 390 insertions(+) create mode 100644 apps/desktop/electron/ipc/provider.ts diff --git a/apps/desktop/electron/electron-env.d.ts b/apps/desktop/electron/electron-env.d.ts index 880abc6a..7777fdf2 100644 --- a/apps/desktop/electron/electron-env.d.ts +++ b/apps/desktop/electron/electron-env.d.ts @@ -101,6 +101,27 @@ interface LocalChatEvent { } } +interface ProviderStatus { + id: string + name: string + authMethod: 'api-key' | 'oauth' + available: boolean + configured: boolean + current: boolean + defaultModel: string + models: string[] + loginUrl?: string + loginCommand?: string + loginInstructions?: string +} + +interface CurrentProviderInfo { + provider: string + model: string | undefined + providerName: string | undefined + available: boolean +} + interface ElectronAPI { hub: { init: () => Promise @@ -145,6 +166,16 @@ interface ElectronAPI { updateStyle: (style: string) => Promise updateUser: (content: string) => Promise } + provider: { + list: () => Promise + listAvailable: () => Promise + current: () => Promise + set: (providerId: string, modelId?: string) => Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> + getMeta: (providerId: string) => Promise + isAvailable: (providerId: string) => Promise + saveApiKey: (providerId: string, apiKey: string) => Promise<{ ok: boolean; error?: string }> + importOAuth: (providerId: string) => Promise<{ ok: boolean; expiresAt?: number; error?: string }> + } localChat: { subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }> unsubscribe: (agentId: string) => Promise<{ ok: boolean }> diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts index d0971eb0..fc11179c 100644 --- a/apps/desktop/electron/ipc/index.ts +++ b/apps/desktop/electron/ipc/index.ts @@ -5,11 +5,13 @@ export { registerAgentIpcHandlers, cleanupAgent } from './agent.js' export { registerSkillsIpcHandlers } from './skills.js' export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js' export { registerProfileIpcHandlers } from './profile.js' +export { registerProviderIpcHandlers } from './provider.js' import { registerAgentIpcHandlers, cleanupAgent } from './agent.js' import { registerSkillsIpcHandlers } from './skills.js' import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js' import { registerProfileIpcHandlers } from './profile.js' +import { registerProviderIpcHandlers } from './provider.js' /** * Register all IPC handlers. @@ -20,6 +22,7 @@ export function registerAllIpcHandlers(): void { registerAgentIpcHandlers() registerSkillsIpcHandlers() registerProfileIpcHandlers() + registerProviderIpcHandlers() } /** diff --git a/apps/desktop/electron/ipc/provider.ts b/apps/desktop/electron/ipc/provider.ts new file mode 100644 index 00000000..3f31dc9c --- /dev/null +++ b/apps/desktop/electron/ipc/provider.ts @@ -0,0 +1,312 @@ +/** + * Provider IPC handlers for Electron main process. + * + * Manages LLM provider listing, status checking, and switching. + * Mirrors the CLI `/provider` command functionality. + */ +import { ipcMain } from 'electron' +import { getCurrentHub } from './hub.js' +import { + getProviderList, + getAvailableProviders, + getCurrentProvider, + getProviderMeta, + isProviderAvailable, + getLoginInstructions, + type ProviderInfo, +} from '../../../../src/agent/providers/index.js' +import { + readClaudeCliCredentials, + readCodexCliCredentials, +} from '../../../../src/agent/providers/oauth/cli-credentials.js' +import { credentialManager } from '../../../../src/agent/credentials.js' + +/** + * Provider info returned to renderer (matches ProviderInfo from registry). + */ +export interface ProviderStatus { + id: string + name: string + authMethod: 'api-key' | 'oauth' + available: boolean + configured: boolean + current: boolean + defaultModel: string + models: string[] + loginUrl?: string + loginCommand?: string + loginInstructions?: string +} + +/** + * Current provider/model info returned to renderer. + */ +export interface CurrentProviderInfo { + provider: string + model: string | undefined + providerName: string | undefined + available: boolean +} + +/** + * Get the default agent from Hub. + */ +function getDefaultAgent() { + const hub = getCurrentHub() + if (!hub) return null + + const agentIds = hub.listAgents() + if (agentIds.length === 0) return null + + return hub.getAgent(agentIds[0]) ?? null +} + +/** + * Register all Provider-related IPC handlers. + */ +export function registerProviderIpcHandlers(): void { + /** + * List all providers with their status. + * This is the main listing function, similar to CLI `/provider` command. + */ + ipcMain.handle('provider:list', async (): Promise => { + const providers = getProviderList() + + return providers.map((p: ProviderInfo) => ({ + id: p.id, + name: p.name, + authMethod: p.authMethod, + available: p.available, + configured: p.configured, + current: p.current, + defaultModel: p.defaultModel, + models: p.models, + loginUrl: p.loginUrl, + loginCommand: p.loginCommand, + loginInstructions: getLoginInstructions(p.id), + })) + }) + + /** + * List only available (configured) providers. + */ + ipcMain.handle('provider:listAvailable', async (): Promise => { + const providers = getAvailableProviders() + + return providers.map((p: ProviderInfo) => ({ + id: p.id, + name: p.name, + authMethod: p.authMethod, + available: p.available, + configured: p.configured, + current: p.current, + defaultModel: p.defaultModel, + models: p.models, + loginUrl: p.loginUrl, + loginCommand: p.loginCommand, + loginInstructions: getLoginInstructions(p.id), + })) + }) + + /** + * Get current provider and model from the active agent. + */ + ipcMain.handle('provider:current', async (): Promise => { + const agent = getDefaultAgent() + + if (agent) { + // Get from actual agent instance + const info = agent.getProviderInfo() + const meta = getProviderMeta(info.provider) + + return { + provider: info.provider, + model: info.model, + providerName: meta?.name, + available: isProviderAvailable(info.provider), + } + } + + // Fallback to credentials default + const defaultProvider = getCurrentProvider() + const meta = getProviderMeta(defaultProvider) + + return { + provider: defaultProvider, + model: meta?.defaultModel, + providerName: meta?.name, + available: isProviderAvailable(defaultProvider), + } + }) + + /** + * Switch the agent to a different provider and/or model. + */ + ipcMain.handle( + 'provider:set', + async (_event, providerId: string, modelId?: string): Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> => { + const agent = getDefaultAgent() + + if (!agent) { + return { ok: false, error: 'No agent available' } + } + + // Validate provider exists + const meta = getProviderMeta(providerId) + if (!meta) { + return { ok: false, error: `Unknown provider: ${providerId}` } + } + + // Check if provider is available + if (!isProviderAvailable(providerId)) { + const instructions = getLoginInstructions(providerId) + return { + ok: false, + error: `Provider "${providerId}" is not configured.\n${instructions}`, + } + } + + // Validate model if specified + if (modelId && !meta.models.includes(modelId)) { + return { + ok: false, + error: `Model "${modelId}" is not available for provider "${providerId}". Available: ${meta.models.join(', ')}`, + } + } + + try { + const result = agent.setProvider(providerId, modelId) + console.log(`[IPC] Provider switched to: ${result.provider}, model: ${result.model}`) + + return { + ok: true, + provider: result.provider, + model: result.model, + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`[IPC] Failed to switch provider: ${message}`) + return { ok: false, error: message } + } + } + ) + + /** + * Get metadata for a specific provider. + */ + ipcMain.handle('provider:getMeta', async (_event, providerId: string) => { + const meta = getProviderMeta(providerId) + if (!meta) { + return { error: `Unknown provider: ${providerId}` } + } + + return { + id: meta.id, + name: meta.name, + authMethod: meta.authMethod, + defaultModel: meta.defaultModel, + models: meta.models, + loginUrl: meta.loginUrl, + loginCommand: meta.loginCommand, + available: isProviderAvailable(providerId), + loginInstructions: getLoginInstructions(providerId), + } + }) + + /** + * Check if a specific provider is available (has valid credentials). + */ + ipcMain.handle('provider:isAvailable', async (_event, providerId: string): Promise => { + return isProviderAvailable(providerId) + }) + + /** + * Save API key for a provider to credentials.json5. + * After saving, the provider should become available. + */ + ipcMain.handle( + 'provider:saveApiKey', + async (_event, providerId: string, apiKey: string): Promise<{ ok: boolean; error?: string }> => { + try { + // Validate provider exists and uses API key auth + const meta = getProviderMeta(providerId) + if (!meta) { + return { ok: false, error: `Unknown provider: ${providerId}` } + } + if (meta.authMethod !== 'api-key') { + return { ok: false, error: `Provider "${providerId}" uses ${meta.authMethod} authentication, not API key` } + } + + // Save the API key + credentialManager.setLlmProviderApiKey(providerId, apiKey) + console.log(`[IPC] API key saved for provider: ${providerId}`) + + return { ok: true } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`[IPC] Failed to save API key: ${message}`) + return { ok: false, error: message } + } + } + ) + + /** + * Import OAuth credentials from CLI tools (claude-code, codex). + * Reads from CLI credential storage and saves to credentials.json5. + */ + ipcMain.handle( + 'provider:importOAuth', + async (_event, providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> => { + try { + const meta = getProviderMeta(providerId) + if (!meta) { + return { ok: false, error: `Unknown provider: ${providerId}` } + } + if (meta.authMethod !== 'oauth') { + return { ok: false, error: `Provider "${providerId}" does not use OAuth authentication` } + } + + // Read credentials from CLI tool + if (providerId === 'claude-code') { + const creds = readClaudeCliCredentials() + if (!creds) { + return { ok: false, error: 'No Claude Code credentials found. Run "claude login" first.' } + } + if (creds.expires <= Date.now()) { + return { ok: false, error: 'Claude Code credentials have expired. Run "claude login" again.' } + } + + // Save to credentials.json5 + const token = creds.type === 'oauth' ? creds.access : creds.token + const refreshToken = creds.type === 'oauth' ? creds.refresh : undefined + credentialManager.setLlmProviderOAuthToken(providerId, token, refreshToken, creds.expires) + console.log(`[IPC] OAuth credentials imported for: ${providerId}`) + + return { ok: true, expiresAt: creds.expires } + } + + if (providerId === 'openai-codex') { + const creds = readCodexCliCredentials() + if (!creds) { + return { ok: false, error: 'No Codex credentials found. Run "codex login" first.' } + } + if (creds.expires <= Date.now()) { + return { ok: false, error: 'Codex credentials have expired. Run "codex login" again.' } + } + + // Save to credentials.json5 + credentialManager.setLlmProviderOAuthToken(providerId, creds.access, creds.refresh, creds.expires) + console.log(`[IPC] OAuth credentials imported for: ${providerId}`) + + return { ok: true, expiresAt: creds.expires } + } + + return { ok: false, error: `OAuth import not supported for provider: ${providerId}` } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`[IPC] Failed to import OAuth credentials: ${message}`) + return { ok: false, error: message } + } + } + ) +} diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index f554ef4b..16093ac0 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -44,6 +44,27 @@ export interface ProfileData { userContent: string | undefined } +export interface ProviderStatus { + id: string + name: string + authMethod: 'api-key' | 'oauth' + available: boolean + configured: boolean + current: boolean + defaultModel: string + models: string[] + loginUrl?: string + loginCommand?: string + loginInstructions?: string +} + +export interface CurrentProviderInfo { + provider: string + model: string | undefined + providerName: string | undefined + available: boolean +} + // Local chat event types (for direct IPC communication without Gateway) export interface LocalChatEvent { agentId: string @@ -134,6 +155,29 @@ const electronAPI = { updateUser: (content: string) => ipcRenderer.invoke('profile:updateUser', content), }, + // Provider management + provider: { + /** List all providers with their status */ + list: (): Promise => ipcRenderer.invoke('provider:list'), + /** List only available (configured) providers */ + listAvailable: (): Promise => ipcRenderer.invoke('provider:listAvailable'), + /** Get current provider and model from the active agent */ + current: (): Promise => ipcRenderer.invoke('provider:current'), + /** Switch the agent to a different provider and/or model */ + set: (providerId: string, modelId?: string): Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> => + ipcRenderer.invoke('provider:set', providerId, modelId), + /** Get metadata for a specific provider */ + getMeta: (providerId: string) => ipcRenderer.invoke('provider:getMeta', providerId), + /** Check if a specific provider is available */ + isAvailable: (providerId: string): Promise => ipcRenderer.invoke('provider:isAvailable', providerId), + /** Save API key for a provider */ + saveApiKey: (providerId: string, apiKey: string): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke('provider:saveApiKey', providerId, apiKey), + /** Import OAuth credentials from CLI tools (claude-code, codex) */ + importOAuth: (providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> => + ipcRenderer.invoke('provider:importOAuth', providerId), + }, + // Local chat (direct IPC, no Gateway required) localChat: { /** Subscribe to agent events for local direct chat */ From 197b77e0432079e0055d939176ed8b278c72232b Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 4 Feb 2026 18:25:20 +0800 Subject: [PATCH 09/30] feat(desktop): add provider selection UI - Add useProvider hook for provider state management - Add ApiKeyDialog for configuring API key providers - Add OAuthDialog for importing OAuth credentials from CLI tools - Update home page with provider dropdown selector - Support switching providers mid-conversation Co-Authored-By: Claude Opus 4.5 --- .../desktop/src/components/api-key-dialog.tsx | 121 ++++++++++++ apps/desktop/src/components/oauth-dialog.tsx | 146 +++++++++++++++ apps/desktop/src/hooks/use-provider.ts | 101 ++++++++++ apps/desktop/src/pages/home.tsx | 174 ++++++++++++++++-- 4 files changed, 527 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/components/api-key-dialog.tsx create mode 100644 apps/desktop/src/components/oauth-dialog.tsx create mode 100644 apps/desktop/src/hooks/use-provider.ts diff --git a/apps/desktop/src/components/api-key-dialog.tsx b/apps/desktop/src/components/api-key-dialog.tsx new file mode 100644 index 00000000..672cc9a8 --- /dev/null +++ b/apps/desktop/src/components/api-key-dialog.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from '@multica/ui/components/ui/dialog' +import { Button } from '@multica/ui/components/ui/button' +import { Input } from '@multica/ui/components/ui/input' +import { Label } from '@multica/ui/components/ui/label' +import { HugeiconsIcon } from '@hugeicons/react' +import { Loading03Icon, Key01Icon } from '@hugeicons/core-free-icons' + +interface ApiKeyDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + providerId: string + providerName: string + onSuccess?: () => void +} + +export function ApiKeyDialog({ + open, + onOpenChange, + providerId, + providerName, + onSuccess, +}: ApiKeyDialogProps) { + const [apiKey, setApiKey] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const handleSave = async () => { + if (!apiKey.trim()) { + setError('API key is required') + return + } + + setSaving(true) + setError(null) + + try { + const result = await window.electronAPI.provider.saveApiKey(providerId, apiKey.trim()) + if (result.ok) { + setApiKey('') + onOpenChange(false) + onSuccess?.() + } else { + setError(result.error ?? 'Failed to save API key') + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message) + } finally { + setSaving(false) + } + } + + const handleClose = (isOpen: boolean) => { + if (!isOpen) { + setApiKey('') + setError(null) + } + onOpenChange(isOpen) + } + + return ( + + + + + + Configure {providerName} + + + Enter your API key to enable {providerName}. The key will be saved securely in your credentials file. + + + +
+
+ + setApiKey(e.target.value)} + placeholder="sk-..." + onKeyDown={(e) => { + if (e.key === 'Enter' && !saving) { + handleSave() + } + }} + /> + {error && ( +

{error}

+ )} +
+ +

+ Your API key is stored locally in ~/.super-multica/credentials.json5 +

+
+ + + + + +
+
+ ) +} + +export default ApiKeyDialog diff --git a/apps/desktop/src/components/oauth-dialog.tsx b/apps/desktop/src/components/oauth-dialog.tsx new file mode 100644 index 00000000..e6573cf0 --- /dev/null +++ b/apps/desktop/src/components/oauth-dialog.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from '@multica/ui/components/ui/dialog' +import { Button } from '@multica/ui/components/ui/button' +import { HugeiconsIcon } from '@hugeicons/react' +import { Loading03Icon, CommandLineIcon, RefreshIcon, Tick02Icon } from '@hugeicons/core-free-icons' + +interface OAuthDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + providerId: string + providerName: string + loginCommand?: string + onSuccess?: () => void +} + +export function OAuthDialog({ + open, + onOpenChange, + providerId, + providerName, + loginCommand, + onSuccess, +}: OAuthDialogProps) { + const [importing, setImporting] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + const [expiresAt, setExpiresAt] = useState(null) + + const handleImport = async () => { + setImporting(true) + setError(null) + setSuccess(false) + + try { + const result = await window.electronAPI.provider.importOAuth(providerId) + if (result.ok) { + setSuccess(true) + setExpiresAt(result.expiresAt ?? null) + // Auto-close after a short delay + setTimeout(() => { + onOpenChange(false) + onSuccess?.() + }, 1500) + } else { + setError(result.error ?? 'Failed to import credentials') + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message) + } finally { + setImporting(false) + } + } + + const handleClose = (isOpen: boolean) => { + if (!isOpen) { + setError(null) + setSuccess(false) + setExpiresAt(null) + } + onOpenChange(isOpen) + } + + const formatExpiry = (timestamp: number) => { + const remaining = timestamp - Date.now() + if (remaining <= 0) return 'expired' + const hours = Math.floor(remaining / (60 * 60 * 1000)) + const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000)) + if (hours > 0) return `${hours}h ${minutes}m` + return `${minutes}m` + } + + return ( + + + + + + Configure {providerName} + + + {providerName} uses OAuth authentication. Please log in via the command line first. + + + +
+ {/* Login instructions */} +
+

+ 1. Open your terminal and run: +

+
+ {loginCommand ?? `${providerId} login`} +
+

+ 2. Complete the login process in your browser +

+

+ 3. Click "Refresh" below to import your credentials +

+
+ + {/* Status messages */} + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ + + Credentials imported successfully! + {expiresAt && ` (expires in ${formatExpiry(expiresAt)})`} + +
+ )} +
+ + + + + +
+
+ ) +} + +export default OAuthDialog diff --git a/apps/desktop/src/hooks/use-provider.ts b/apps/desktop/src/hooks/use-provider.ts new file mode 100644 index 00000000..99cae540 --- /dev/null +++ b/apps/desktop/src/hooks/use-provider.ts @@ -0,0 +1,101 @@ +/** + * Hook for managing LLM providers in the Desktop App. + * + * Provides functionality similar to CLI `/provider` command: + * - List all providers with status + * - Get current provider/model + * - Switch provider/model + */ +import { useState, useEffect, useCallback } from 'react' + +// Types are defined in electron-env.d.ts and available globally + +interface UseProviderReturn { + /** All providers with their status */ + providers: ProviderStatus[] + /** Only available (configured) providers */ + availableProviders: ProviderStatus[] + /** Current provider and model info */ + current: CurrentProviderInfo | null + /** Loading state */ + loading: boolean + /** Error message if any */ + error: string | null + /** Refresh provider list and current status */ + refresh: () => Promise + /** Switch to a different provider (and optionally model) */ + setProvider: (providerId: string, modelId?: string) => Promise<{ ok: boolean; error?: string }> + /** Get metadata for a specific provider */ + getProviderMeta: (providerId: string) => ProviderStatus | undefined +} + +export function useProvider(): UseProviderReturn { + const [providers, setProviders] = useState([]) + const [current, setCurrent] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const refresh = useCallback(async () => { + setLoading(true) + setError(null) + + try { + const [providerList, currentInfo] = await Promise.all([ + window.electronAPI.provider.list(), + window.electronAPI.provider.current(), + ]) + + setProviders(providerList) + setCurrent(currentInfo) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message) + console.error('[useProvider] Failed to load providers:', message) + } finally { + setLoading(false) + } + }, []) + + // Load providers on mount + useEffect(() => { + refresh() + }, [refresh]) + + const setProvider = useCallback(async (providerId: string, modelId?: string) => { + setError(null) + + try { + const result = await window.electronAPI.provider.set(providerId, modelId) + + if (result.ok) { + // Refresh to update current status + await refresh() + return { ok: true } + } else { + setError(result.error ?? 'Unknown error') + return { ok: false, error: result.error } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message) + return { ok: false, error: message } + } + }, [refresh]) + + const getProviderMeta = useCallback((providerId: string) => { + return providers.find((p) => p.id === providerId) + }, [providers]) + + const availableProviders = providers.filter((p) => p.available) + + return { + providers, + availableProviders, + current, + loading, + error, + refresh, + setProvider, + getProviderMeta, + } +} diff --git a/apps/desktop/src/pages/home.tsx b/apps/desktop/src/pages/home.tsx index ba2767f0..56396e53 100644 --- a/apps/desktop/src/pages/home.tsx +++ b/apps/desktop/src/pages/home.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { Button } from '@multica/ui/components/ui/button' import { HugeiconsIcon } from '@hugeicons/react' @@ -8,17 +8,52 @@ import { Loading03Icon, AlertCircleIcon, Edit02Icon, + ArrowDown01Icon, + Tick02Icon, + Alert02Icon, } from '@hugeicons/core-free-icons' import { ConnectionQRCode } from '../components/qr-code' import { DeviceList } from '../components/device-list' import { AgentSettingsDialog } from '../components/agent-settings-dialog' +import { ApiKeyDialog } from '../components/api-key-dialog' +import { OAuthDialog } from '../components/oauth-dialog' import { useHub } from '../hooks/use-hub' +import { useProvider } from '../hooks/use-provider' export default function HomePage() { const navigate = useNavigate() const { hubInfo, agents, loading, error } = useHub() + const { providers, current, setProvider, refresh, loading: providerLoading } = useProvider() const [settingsOpen, setSettingsOpen] = useState(false) const [agentName, setAgentName] = useState() + const [providerDropdownOpen, setProviderDropdownOpen] = useState(false) + const [switching, setSwitching] = useState(false) + const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false) + const [oauthDialogOpen, setOauthDialogOpen] = useState(false) + const [selectedProvider, setSelectedProvider] = useState<{ + id: string + name: string + authMethod: 'api-key' | 'oauth' + loginCommand?: string + } | null>(null) + const dropdownRef = useRef(null) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setProviderDropdownOpen(false) + } + } + + if (providerDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [providerDropdownOpen]) // Load agent profile info useEffect(() => { @@ -151,6 +186,92 @@ export default function HomePage() {

{agentName || 'Unnamed Agent'}

+ {/* Provider Selector */} +
+

+ LLM Provider +

+ + + {/* Provider Dropdown - Compact Grid */} + {providerDropdownOpen && ( +
+
+ {providers.map((p) => ( + + ))} +
+
+ )} +
+ {/* Stats Grid */}
@@ -167,20 +288,6 @@ export default function HomePage() {

{connectionState}

-
-

- Active Agents -

-

{hubInfo?.agentCount ?? 0}

-
-
-

- Primary Agent -

-

- {primaryAgent?.id ?? 'None'} -

-
@@ -194,6 +301,43 @@ export default function HomePage() { {/* Agent Settings Dialog */} + {/* API Key Dialog */} + {selectedProvider && selectedProvider.authMethod === 'api-key' && ( + { + // Refresh provider list and switch to the newly configured provider + await refresh() + const result = await setProvider(selectedProvider.id) + if (!result.ok) { + console.error('Failed to switch provider:', result.error) + } + }} + /> + )} + + {/* OAuth Dialog */} + {selectedProvider && selectedProvider.authMethod === 'oauth' && ( + { + // Refresh provider list and switch to the newly configured provider + await refresh() + const result = await setProvider(selectedProvider.id) + if (!result.ok) { + console.error('Failed to switch provider:', result.error) + } + }} + /> + )} + {/* Bottom: Actions */}
From e4ce3349f8acfccf16c63e9036ea515cb4c82893 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 4 Feb 2026 18:25:26 +0800 Subject: [PATCH 10/30] docs(desktop): update TODO list in README Co-Authored-By: Claude Opus 4.5 --- apps/desktop/README.md | 42 ++++++++++++++---------------------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 44fc7955..0e202352 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -190,28 +190,6 @@ ipcMain.handle('skills:add', async (_, source: string) => { --- -## 三、实现优先级 - -### Phase 1: 基础框架 (MVP) - -1. **Layout 组件** - Header + Tabs 导航 -2. **Home 页面** - 二维码显示 + 连接状态 -3. **Gateway 连接** - 复用 @multica/store - -### Phase 2: 管理功能 - -4. **Tools 页面** - 列表展示 + 开关切换 -5. **Skills 页面** - 列表展示 + 基础操作 -6. **Settings** - Gateway URL + Theme - -### Phase 3: 完善体验 - -7. **Agent 页面** - 状态监控 + Provider 切换 -8. **二维码刷新机制** -9. **错误处理 + Toast 提示** - ---- - ## 四、Hub 集成技术方案 ### 架构概述 @@ -496,9 +474,17 @@ ChatInput → useMessagesStore.sendMessage() ### 复用层级 -| 层级 | 组件/模块 | 复用情况 | -| ---------- | ---------------------------------------- | -------- | -| UI 层 | `MessageList`, `ChatInput` | ✅ 完全复用 | -| Store 层 | `useMessagesStore` | ✅ 完全复用 | -| Agent 层 | `AsyncAgent.write()`, `subscribe()` | ✅ 完全复用 | -| 传输层 | IPC vs WebSocket | ❌ 各自实现 | +| 层级 | 组件/模块 | 复用情况 | +| -------- | ----------------------------------- | ----------- | +| UI 层 | `MessageList`, `ChatInput` | ✅ 完全复用 | +| Store 层 | `useMessagesStore` | ✅ 完全复用 | +| Agent 层 | `AsyncAgent.write()`, `subscribe()` | ✅ 完全复用 | +| 传输层 | IPC vs WebSocket | ❌ 各自实现 | + +--- + +## 九、TODO + +- [ ] **优化 Memory Tool 逻辑**: 当前 memory tool 和 memory.md 没有统一,需要整合 +- [ ] **优化 Agent Profile 加载逻辑**: 改进 Profile 的加载机制 +- [ ] **Agent 自我迭代 Profile**: 添加让 Agent 在对话过程中自己修改 Profile 内文件的能力 From e4f1d5145327a477170d3a6089921f33fc97fcb4 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:39:32 +0800 Subject: [PATCH 11/30] feat(store,ui): handle Hub error messages and display error banner - Handle `action: "error"` messages in connection-store (e.g. UNAUTHORIZED) - Widen lastError type to `{code, message}` to support all error codes - Display dismissible error banner in Chat with role="alert" and aria-live - Add accessible close button with focus-visible ring Co-Authored-By: Claude Opus 4.5 --- packages/store/src/connection-store.ts | 12 +++++++++--- packages/ui/src/components/chat.tsx | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/store/src/connection-store.ts b/packages/store/src/connection-store.ts index b2951b38..fd5c09e0 100644 --- a/packages/store/src/connection-store.ts +++ b/packages/store/src/connection-store.ts @@ -20,7 +20,6 @@ import { GatewayClient, StreamAction, type ConnectionState, - type SendErrorResponse, type StreamPayload, type AgentEvent, type GetAgentMessagesResult, @@ -35,7 +34,7 @@ interface ConnectionStoreState { hubId: string | null agentId: string | null connectionState: ConnectionState - lastError: SendErrorResponse | null + lastError: { code: string; message: string } | null } interface ConnectionStoreActions { @@ -146,13 +145,20 @@ function createClient( return } + // Handle error messages from Hub (e.g. UNAUTHORIZED) + if (msg.action === "error") { + const payload = msg.payload as { code: string; message: string } + set({ lastError: { code: payload.code, message: payload.message } }) + return + } + // Handle direct (non-streaming) messages const payload = msg.payload as { agentId?: string; content?: string } if (payload?.agentId && payload?.content) { useMessagesStore.getState().addAssistantMessage(payload.content, payload.agentId) } }) - .onSendError((error) => set({ lastError: error })) + .onSendError((error) => set({ lastError: { code: error.code, message: error.error } })) } /** Fetch message history from Hub via RPC after connection is established */ diff --git a/packages/ui/src/components/chat.tsx b/packages/ui/src/components/chat.tsx index fa3d7773..60f7519a 100644 --- a/packages/ui/src/components/chat.tsx +++ b/packages/ui/src/components/chat.tsx @@ -16,6 +16,7 @@ export function Chat() { const agentId = useConnectionStore((s) => s.agentId) const gwState = useConnectionStore((s) => s.connectionState) const hubId = useConnectionStore((s) => s.hubId) + const lastError = useConnectionStore((s) => s.lastError) const messages = useMessagesStore((s) => s.messages) const streamingIds = useMessagesStore((s) => s.streamingIds) @@ -65,6 +66,23 @@ export function Chat() { )} + {/* Error banner */} + {lastError && ( +
+
+ {lastError.message} ({lastError.code}) + +
+
+ )} + {/* Footer */}
Date: Wed, 4 Feb 2026 18:50:44 +0800 Subject: [PATCH 12/30] fix(desktop): update use-local-chat to use ContentBlock[] instead of string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Message.content changed from string to ContentBlock[] — update desktop's local chat hook to match: extractContentFromAgentEvent returns ContentBlock[], and history messages are normalized from string to ContentBlock[]. Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/hooks/use-local-chat.ts | 41 +++++++++++++----------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/hooks/use-local-chat.ts b/apps/desktop/src/hooks/use-local-chat.ts index caacf681..ebe19d0c 100644 --- a/apps/desktop/src/hooks/use-local-chat.ts +++ b/apps/desktop/src/hooks/use-local-chat.ts @@ -6,6 +6,7 @@ */ import { useState, useEffect, useCallback, useRef } from 'react' import { useMessagesStore } from '@multica/store' +import type { ContentBlock } from '@multica/sdk' interface UseLocalChatOptions { agentId: string @@ -45,7 +46,15 @@ export function useLocalChat({ agentId }: UseLocalChatOptions): UseLocalChatRetu try { const result = await window.electronAPI.localChat.getHistory(agentId) if (result.messages && result.messages.length > 0) { - useMessagesStore.getState().loadMessages(result.messages) + // Normalize: IPC may return content as string, store expects ContentBlock[] + useMessagesStore.getState().loadMessages( + result.messages.map((m: Record) => ({ + ...m, + content: typeof m.content === 'string' + ? (m.content ? [{ type: 'text' as const, text: m.content }] : []) + : (m.content ?? []), + })) as import('@multica/store').Message[] + ) } } catch { // History load is best-effort @@ -74,18 +83,17 @@ export function useLocalChat({ agentId }: UseLocalChatOptions): UseLocalChatRetu if (agentEvent.type === 'message_start') { currentStreamRef.current = streamId store.startStream(streamId, agentId) - // Extract initial text if any - const text = extractTextFromAgentEvent(agentEvent) - if (text) store.appendStream(streamId, text) + const content = extractContentFromAgentEvent(agentEvent) + if (content.length) store.appendStream(streamId, content) } else if (agentEvent.type === 'message_update') { - const text = extractTextFromAgentEvent(agentEvent) - if (text && currentStreamRef.current) { - store.appendStream(currentStreamRef.current, text) + const content = extractContentFromAgentEvent(agentEvent) + if (content.length && currentStreamRef.current) { + store.appendStream(currentStreamRef.current, content) } } else if (agentEvent.type === 'message_end') { - const text = extractTextFromAgentEvent(agentEvent) + const content = extractContentFromAgentEvent(agentEvent) if (currentStreamRef.current) { - store.endStream(currentStreamRef.current, text) + store.endStream(currentStreamRef.current, content) currentStreamRef.current = null } setIsLoading(false) @@ -131,14 +139,9 @@ export function useLocalChat({ agentId }: UseLocalChatOptions): UseLocalChatRetu } } -/** - * Extract text content from AgentEvent message. - * Same logic as @multica/sdk extractTextFromEvent. - */ -function extractTextFromAgentEvent(event: { message?: { content?: Array<{ type: string; text?: string }> } }): string { - if (!event.message?.content) return '' - return event.message.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text' && !!c.text) - .map((c) => c.text) - .join('') +/** Extract content blocks from AgentEvent message */ +function extractContentFromAgentEvent(event: { message?: { content?: unknown } }): ContentBlock[] { + if (!event.message?.content) return [] + const content = event.message.content + return Array.isArray(content) ? content as ContentBlock[] : [] } From 5cdc6974f70d68171904335583f4131ed5adc455 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Thu, 5 Feb 2026 02:41:29 +0800 Subject: [PATCH 13/30] refactor(profile): implement progressive disclosure for profile loading - Only inject workspace.md into system prompt (not soul, user, memory) - Add profile directory path to workspace section for on-demand file access - Agent now reads soul.md, user.md, memory.md on first session using tools - Reduces system prompt size and improves token efficiency - Aligns with workspace.md template instructions ("Read soul.md first") Co-Authored-By: Claude Opus 4.5 --- src/agent/system-prompt/builder.test.ts | 24 ++++-- src/agent/system-prompt/builder.ts | 2 +- src/agent/system-prompt/sections.test.ts | 66 ++++++++------- src/agent/system-prompt/sections.ts | 102 ++++++++++++++--------- 4 files changed, 114 insertions(+), 80 deletions(-) diff --git a/src/agent/system-prompt/builder.test.ts b/src/agent/system-prompt/builder.test.ts index e9657a5e..09a29467 100644 --- a/src/agent/system-prompt/builder.test.ts +++ b/src/agent/system-prompt/builder.test.ts @@ -15,12 +15,13 @@ const TOOLS = ["read", "write", "edit", "glob", "exec", "memory_get", "memory_se describe("buildSystemPrompt", () => { // ── Full mode ───────────────────────────────────────────────────────── - it("full mode includes all profile sections", () => { + it("full mode includes workspace section only (progressive disclosure)", () => { + // Soul, user, memory are read on-demand by the agent const result = buildSystemPrompt({ mode: "full", profile: PROFILE }); - expect(result).toContain("# Soul"); - expect(result).toContain("# User"); + expect(result).not.toContain("# Soul"); + expect(result).not.toContain("# User"); expect(result).toContain("# Workspace"); - expect(result).toContain("# Memory"); + expect(result).not.toContain("# Memory"); }); it("full mode includes safety constitution", () => { @@ -76,13 +77,17 @@ describe("buildSystemPrompt", () => { expect(result).toContain("os=darwin (arm64)"); }); - it("full mode includes profile directory", () => { + it("full mode includes profile info in workspace section", () => { const result = buildSystemPrompt({ mode: "full", profileDir: "/home/user/.super-multica/agent-profiles/test", + profile: { workspace: "Workspace rules" }, }); - expect(result).toContain("## Profile Directory"); + expect(result).toContain("## Profile"); expect(result).toContain("/home/user/.super-multica/agent-profiles/test"); + expect(result).toContain("soul.md"); + expect(result).toContain("user.md"); + expect(result).toContain("memory.md"); }); it("full mode excludes subagent section", () => { @@ -242,8 +247,13 @@ describe("buildSystemPromptWithReport", () => { it("report marks excluded sections correctly in minimal mode", () => { const { report } = buildSystemPromptWithReport({ mode: "minimal" }); + // Identity is now included in all modes (just a one-liner) const identity = report.sections.find((s) => s.name === "identity"); - expect(identity?.included).toBe(false); + expect(identity?.included).toBe(true); + + // User and memory are excluded (progressive disclosure) + const user = report.sections.find((s) => s.name === "user"); + expect(user?.included).toBe(false); const safety = report.sections.find((s) => s.name === "safety"); expect(safety?.included).toBe(true); diff --git a/src/agent/system-prompt/builder.ts b/src/agent/system-prompt/builder.ts index b0f1f45f..619b05f8 100644 --- a/src/agent/system-prompt/builder.ts +++ b/src/agent/system-prompt/builder.ts @@ -56,7 +56,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): { const candidates: Array<{ name: string; lines: string[] }> = [ { name: "identity", lines: buildIdentitySection(profile, mode) }, { name: "user", lines: buildUserSection(profile, mode) }, - { name: "workspace", lines: buildWorkspaceSection(profile, mode) }, + { name: "workspace", lines: buildWorkspaceSection(profile, mode, profileDir) }, { name: "memory", lines: buildMemoryFileSection(profile, mode) }, { name: "safety", lines: buildSafetySection(includeSafety) }, { name: "tooling", lines: buildToolingSummary(tools, mode) }, diff --git a/src/agent/system-prompt/sections.test.ts b/src/agent/system-prompt/sections.test.ts index f98ccabd..cfd25f6f 100644 --- a/src/agent/system-prompt/sections.test.ts +++ b/src/agent/system-prompt/sections.test.ts @@ -15,9 +15,15 @@ import { } from "./sections.js"; describe("buildIdentitySection", () => { - it("returns soul content in full mode", () => { + it("returns identity line in full mode (progressive disclosure)", () => { + // Soul content is no longer injected - agent reads soul.md on demand + const result = buildIdentitySection({ soul: "You are helpful.", config: { name: "Cleo" } }, "full"); + expect(result).toEqual(["You are Cleo, a Super Multica agent."]); + }); + + it("returns generic identity line in full mode without name", () => { const result = buildIdentitySection({ soul: "You are helpful." }, "full"); - expect(result).toEqual(["You are helpful."]); + expect(result).toEqual(["You are a Super Multica agent."]); }); it("returns identity line with name in none mode", () => { @@ -30,47 +36,48 @@ describe("buildIdentitySection", () => { expect(result).toEqual(["You are a Super Multica agent."]); }); - it("returns empty in minimal mode", () => { - const result = buildIdentitySection({ soul: "data" }, "minimal"); - expect(result).toEqual([]); + it("returns identity line in minimal mode", () => { + const result = buildIdentitySection({ soul: "data", config: { name: "Cleo" } }, "minimal"); + expect(result).toEqual(["You are Cleo, a Super Multica agent."]); }); }); describe("buildUserSection", () => { - it("returns user content in full mode", () => { - const result = buildUserSection({ user: "Name: Bob" }, "full"); - expect(result).toEqual(["Name: Bob"]); - }); - - it("returns empty in minimal mode", () => { - const result = buildUserSection({ user: "data" }, "minimal"); - expect(result).toEqual([]); - }); - - it("returns empty when no user content", () => { - const result = buildUserSection({}, "full"); - expect(result).toEqual([]); + it("returns empty in all modes (progressive disclosure)", () => { + // User content is no longer injected - agent reads user.md on demand + expect(buildUserSection({ user: "Name: Bob" }, "full")).toEqual([]); + expect(buildUserSection({ user: "data" }, "minimal")).toEqual([]); + expect(buildUserSection({}, "full")).toEqual([]); }); }); describe("buildWorkspaceSection", () => { - it("returns workspace content in full mode", () => { + it("returns workspace content with profile info in full mode", () => { + const result = buildWorkspaceSection({ workspace: "Rules here" }, "full", "/path/to/profile"); + const text = result.join("\n"); + expect(text).toContain("## Profile"); + expect(text).toContain("/path/to/profile"); + expect(text).toContain("soul.md"); + expect(text).toContain("user.md"); + expect(text).toContain("memory.md"); + expect(text).toContain("Rules here"); + }); + + it("returns workspace content without profile dir", () => { const result = buildWorkspaceSection({ workspace: "Rules here" }, "full"); expect(result).toEqual(["Rules here"]); }); it("returns empty in minimal mode", () => { expect(buildWorkspaceSection({ workspace: "data" }, "minimal")).toEqual([]); + expect(buildWorkspaceSection({ workspace: "data" }, "minimal", "/path")).toEqual([]); }); }); describe("buildMemoryFileSection", () => { - it("returns memory content in full mode", () => { - const result = buildMemoryFileSection({ memory: "Key facts" }, "full"); - expect(result).toEqual(["Key facts"]); - }); - - it("returns empty in minimal mode", () => { + it("returns empty in all modes (progressive disclosure)", () => { + // Memory content is no longer injected - agent reads memory.md on demand + expect(buildMemoryFileSection({ memory: "Key facts" }, "full")).toEqual([]); expect(buildMemoryFileSection({ memory: "data" }, "minimal")).toEqual([]); }); }); @@ -227,12 +234,9 @@ describe("buildRuntimeSection", () => { }); describe("buildProfileDirSection", () => { - it("includes path in full mode", () => { - const result = buildProfileDirSection("/path/to/profile", "full"); - expect(result.join("\n")).toContain("/path/to/profile"); - }); - - it("returns empty in minimal mode", () => { + it("returns empty in all modes (merged into workspace section)", () => { + // Profile directory info is now part of buildWorkspaceSection + expect(buildProfileDirSection("/path/to/profile", "full")).toEqual([]); expect(buildProfileDirSection("/path", "minimal")).toEqual([]); }); }); diff --git a/src/agent/system-prompt/sections.ts b/src/agent/system-prompt/sections.ts index af1569d4..a0126d56 100644 --- a/src/agent/system-prompt/sections.ts +++ b/src/agent/system-prompt/sections.ts @@ -47,59 +47,85 @@ const TOOL_ORDER = [ // ─── Section builders ─────────────────────────────────────────────────────── /** - * Identity section — soul.md in full mode, single line in none mode, nothing in minimal. + * Identity section — brief identity line only. + * Full profile content (soul.md) is loaded on-demand by the agent. */ export function buildIdentitySection( profile: ProfileContent | undefined, mode: SystemPromptMode, ): string[] { - if (mode === "none") { - const name = profile?.config?.name; + const name = profile?.config?.name; + if (mode === "none" || mode === "minimal") { return name ? [`You are ${name}, a Super Multica agent.`] : ["You are a Super Multica agent."]; } - if (mode === "minimal") { - return []; - } - // full mode - if (profile?.soul) { - return [profile.soul]; - } + // full mode - just identity line, agent reads soul.md on demand + return name + ? [`You are ${name}, a Super Multica agent.`] + : ["You are a Super Multica agent."]; +} + +/** + * User section — no longer injected into system prompt. + * Agent reads user.md on demand from profile directory. + */ +export function buildUserSection( + _profile: ProfileContent | undefined, + _mode: SystemPromptMode, +): string[] { + // Progressive disclosure: agent reads user.md on demand return []; } /** - * User section — user.md content (full mode only). - */ -export function buildUserSection( - profile: ProfileContent | undefined, - mode: SystemPromptMode, -): string[] { - if (mode !== "full" || !profile?.user) return []; - return [profile.user]; -} - -/** - * Workspace section — workspace.md content (full mode only). + * Workspace section — workspace.md content with profile directory path. + * This is the primary profile content injected into system prompt. + * Other profile files (soul.md, user.md, memory.md) are read on demand. */ export function buildWorkspaceSection( profile: ProfileContent | undefined, mode: SystemPromptMode, + profileDir?: string, ): string[] { - if (mode !== "full" || !profile?.workspace) return []; - return [profile.workspace]; + if (mode !== "full") return []; + + const lines: string[] = []; + + // Add profile directory context first + if (profileDir) { + lines.push( + "## Profile", + "", + `Your profile directory: \`${profileDir}\``, + "", + "Profile files:", + "- `soul.md` — Your identity and values", + "- `user.md` — Information about your user", + "- `workspace.md` — Guidelines and conventions (below)", + "- `memory.md` — Persistent knowledge", + "", + ); + } + + // Add workspace.md content + if (profile?.workspace) { + lines.push(profile.workspace); + } + + return lines; } /** - * Memory section — memory.md content (full mode only). + * Memory section — no longer injected into system prompt. + * Agent reads memory.md on demand from profile directory. */ export function buildMemoryFileSection( - profile: ProfileContent | undefined, - mode: SystemPromptMode, + _profile: ProfileContent | undefined, + _mode: SystemPromptMode, ): string[] { - if (mode !== "full" || !profile?.memory) return []; - return [profile.memory]; + // Progressive disclosure: agent reads memory.md on demand + return []; } /** @@ -265,21 +291,15 @@ export function buildRuntimeSection( } /** - * Profile directory section — tells agent where its files live. - * Full mode only. + * Profile directory section — now merged into buildWorkspaceSection. + * Kept for backwards compatibility but returns empty. */ export function buildProfileDirSection( - profileDir: string | undefined, - mode: SystemPromptMode, + _profileDir: string | undefined, + _mode: SystemPromptMode, ): string[] { - if (mode !== "full" || !profileDir) return []; - return [ - "## Profile Directory", - "", - `Your profile files are located at: \`${profileDir}\``, - "", - "Use `edit` or `write` tools to update these files when needed.", - ]; + // Profile directory info is now part of workspace section + return []; } /** From 9b16001e0eeb75e76f3618b5796758f44ad1cb46 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Thu, 5 Feb 2026 02:58:08 +0800 Subject: [PATCH 14/30] feat(subagent): pass tools to subagent system prompt Resolve tools before building subagent system prompt so the "## Tooling" section is included, matching OpenClaw's pattern. Co-Authored-By: Claude Opus 4.5 --- src/agent/subagent/announce.ts | 1 + src/agent/subagent/types.ts | 2 ++ src/agent/tools/sessions-spawn.ts | 6 ++++++ 3 files changed, 9 insertions(+) diff --git a/src/agent/subagent/announce.ts b/src/agent/subagent/announce.ts index 7fa27d65..fbfa9800 100644 --- a/src/agent/subagent/announce.ts +++ b/src/agent/subagent/announce.ts @@ -29,6 +29,7 @@ export function buildSubagentSystemPrompt(params: SubagentSystemPromptParams): s label: params.label, task: params.task, }, + tools: params.tools, }); } diff --git a/src/agent/subagent/types.ts b/src/agent/subagent/types.ts index dfbf62bb..d4043572 100644 --- a/src/agent/subagent/types.ts +++ b/src/agent/subagent/types.ts @@ -71,4 +71,6 @@ export type SubagentSystemPromptParams = { childSessionId: string; label?: string | undefined; task: string; + /** Tool names available to the subagent (for tooling summary in system prompt) */ + tools?: string[] | undefined; }; diff --git a/src/agent/tools/sessions-spawn.ts b/src/agent/tools/sessions-spawn.ts index 9ae2cc69..fc93199e 100644 --- a/src/agent/tools/sessions-spawn.ts +++ b/src/agent/tools/sessions-spawn.ts @@ -11,6 +11,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { getHub } from "../../hub/hub-singleton.js"; import { buildSubagentSystemPrompt } from "../subagent/announce.js"; import { registerSubagentRun } from "../subagent/registry.js"; +import { resolveTools } from "../tools.js"; const SessionsSpawnSchema = Type.Object({ task: Type.String({ description: "The task for the subagent to perform.", minLength: 1 }), @@ -84,12 +85,17 @@ export function createSessionsSpawnTool( const runId = uuidv7(); const childSessionId = uuidv7(); + // Resolve tools for the subagent (with isSubagent=true for policy filtering) + const subagentTools = resolveTools({ isSubagent: true }); + const toolNames = subagentTools.map((t) => t.name); + // Build system prompt for the child const systemPrompt = buildSubagentSystemPrompt({ requesterSessionId, childSessionId, label, task, + tools: toolNames, }); // Spawn child agent via Hub From 087d1a8653b61444b63cd84d4298cc2cda1b44aa Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Thu, 5 Feb 2026 02:58:15 +0800 Subject: [PATCH 15/30] refactor(tools): remove tool profile layer from policy system Simplify 4-layer policy to 3-layer: - Layer 1: Global allow/deny (user config) - Layer 2: Provider-specific rules - Layer 3: Subagent restrictions Removed: - ToolProfileId type (minimal/coding/web/full) - TOOL_PROFILES constant - getProfilePolicy function - profile field from ToolsConfig Users can achieve the same effect using allow/deny with group:* syntax. Co-Authored-By: Claude Opus 4.5 --- src/agent/tools/README.md | 151 ++++++++++---------------------- src/agent/tools/README.zh-CN.md | 151 ++++++++++---------------------- src/agent/tools/groups.ts | 49 +---------- src/agent/tools/index.ts | 5 +- src/agent/tools/policy.test.ts | 72 +++++---------- src/agent/tools/policy.ts | 61 ++++--------- 6 files changed, 128 insertions(+), 361 deletions(-) diff --git a/src/agent/tools/README.md b/src/agent/tools/README.md index 80087453..db5b5266 100644 --- a/src/agent/tools/README.md +++ b/src/agent/tools/README.md @@ -19,28 +19,22 @@ The tools system provides LLM agents with capabilities to interact with the exte │ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ 4-Layer Policy Filter │ +│ 3-Layer Policy Filter │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Layer 1: Profile │ │ -│ │ Base tool set: minimal | coding | web | full │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Layer 2: Global Allow/Deny │ │ +│ │ Layer 1: Global Allow/Deny │ │ │ │ User customization via CLI or config │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Layer 3: Provider-Specific │ │ +│ │ Layer 2: Provider-Specific │ │ │ │ Different rules for different LLM providers │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Layer 4: Subagent Restrictions │ │ +│ │ Layer 3: Subagent Restrictions │ │ │ │ Limited tools for spawned child agents │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ @@ -55,20 +49,20 @@ The tools system provides LLM agents with capabilities to interact with the exte ## Available Tools -| Tool | Name | Description | -| ------------- | --------------- | --------------------------------------------- | -| Read | `read` | Read file contents | -| Write | `write` | Write content to files | -| Edit | `edit` | Edit existing files | -| Glob | `glob` | Find files by pattern | -| Exec | `exec` | Execute shell commands | -| Process | `process` | Manage long-running processes | -| Web Fetch | `web_fetch` | Fetch and extract content from URLs | -| Web Search | `web_search` | Search the web (requires API key) | -| Memory Get | `memory_get` | Retrieve a value from persistent memory | -| Memory Set | `memory_set` | Store a value in persistent memory | -| Memory Delete | `memory_delete` | Delete a value from persistent memory | -| Memory List | `memory_list` | List all keys in persistent memory | +| Tool | Name | Description | +| ------------- | --------------- | --------------------------------------- | +| Read | `read` | Read file contents | +| Write | `write` | Write content to files | +| Edit | `edit` | Edit existing files | +| Glob | `glob` | Find files by pattern | +| Exec | `exec` | Execute shell commands | +| Process | `process` | Manage long-running processes | +| Web Fetch | `web_fetch` | Fetch and extract content from URLs | +| Web Search | `web_search` | Search the web (requires API key) | +| Memory Get | `memory_get` | Retrieve a value from persistent memory | +| Memory Set | `memory_set` | Store a value in persistent memory | +| Memory Delete | `memory_delete` | Delete a value from persistent memory | +| Memory List | `memory_list` | List all keys in persistent memory | > **Note**: Memory tools require a `profileId` to be specified. They store data in the profile's memory directory. @@ -76,24 +70,13 @@ The tools system provides LLM agents with capabilities to interact with the exte Groups provide shortcuts for allowing/denying multiple tools at once: -| Group | Tools | -| --------------- | ------------------------------------------------- | -| `group:fs` | read, write, edit, glob | -| `group:runtime` | exec, process | -| `group:web` | web_search, web_fetch | -| `group:memory` | memory_get, memory_set, memory_delete, memory_list| -| `group:core` | All of the above (excluding memory) | - -## Tool Profiles - -Profiles are predefined tool sets for common use cases: - -| Profile | Description | Tools | -| --------- | ----------------------- | ---------------------------------- | -| `minimal` | No tools (chat-only) | None | -| `coding` | File system + execution | group:fs, group:runtime | -| `web` | Coding + web access | group:fs, group:runtime, group:web | -| `full` | No restrictions | All tools | +| Group | Tools | +| --------------- | -------------------------------------------------- | +| `group:fs` | read, write, edit, glob | +| `group:runtime` | exec, process | +| `group:web` | web_search, web_fetch | +| `group:memory` | memory_get, memory_set, memory_delete, memory_list | +| `group:core` | All of the above (excluding memory) | ## Usage @@ -102,11 +85,8 @@ Profiles are predefined tool sets for common use cases: All commands use the unified `multica` CLI (or `pnpm multica` during development). ```bash -# Use a specific profile -multica run --tools-profile coding "list files" - -# Minimal profile with specific tools allowed -multica run --tools-profile minimal --tools-allow exec "run ls" +# Allow only specific tools +multica run --tools-allow group:fs,group:runtime "list files" # Deny specific tools multica run --tools-deny exec,process "read file.txt" @@ -122,14 +102,11 @@ import { Agent } from './runner.js'; const agent = new Agent({ tools: { - // Layer 1: Base profile - profile: 'coding', + // Layer 1: Global allow/deny + allow: ['group:fs', 'group:runtime', 'web_fetch'], + deny: ['exec'], - // Layer 2: Global customization - allow: ['web_fetch'], // Add web_fetch to coding profile - deny: ['exec'], // But deny exec - - // Layer 3: Provider-specific rules + // Layer 2: Provider-specific rules byProvider: { google: { deny: ['exec', 'process'], // Google models can't use runtime tools @@ -137,7 +114,7 @@ const agent = new Agent({ }, }, - // Layer 4: Subagent mode + // Layer 3: Subagent mode isSubagent: false, }); ``` @@ -150,43 +127,28 @@ Use the tools CLI to inspect and test configurations: # List all available tools multica tools list -# List tools after applying a profile -multica tools list --profile coding +# List tools with allow rules +multica tools list --allow group:fs,group:runtime # List tools with deny rules -multica tools list --profile coding --deny exec +multica tools list --deny exec # Show all tool groups multica tools groups - -# Show all profiles -multica tools profiles ``` ## Policy System Details -### Layer 1: Profile +### Layer 1: Global Allow/Deny -The profile determines the base set of available tools. If not specified, all tools are available. +User-specified allow/deny lists: -```typescript -// In groups.ts -export const TOOL_PROFILES = { - minimal: { allow: [] }, // No tools - coding: { allow: ['group:fs', 'group:runtime'] }, // FS + execution - web: { allow: ['group:fs', 'group:runtime', 'group:web'] }, // + web - full: {}, // No restrictions -}; -``` +- `allow`: Only these tools are available (supports group:\* syntax) +- `deny`: These tools are blocked (takes precedence over allow) -### Layer 2: Global Allow/Deny +If no `allow` list is specified, all tools are available by default. -User-specified allow/deny lists that modify the profile's tool set: - -- `allow`: Only these tools are available (additive to profile) -- `deny`: These tools are blocked (takes precedence over allow) - -### Layer 3: Provider-Specific +### Layer 2: Provider-Specific Different LLM providers may have different capabilities or restrictions: @@ -199,7 +161,7 @@ Different LLM providers may have different capabilities or restrictions: } ``` -### Layer 4: Subagent Restrictions +### Layer 3: Subagent Restrictions When `isSubagent: true`, additional restrictions are applied to prevent spawned agents from accessing sensitive tools like session management. @@ -280,7 +242,7 @@ Tools configuration can be defined in Agent Profile's `config.json`, allowing di │ │ coder │ │ reviewer │ │ devops │ │ │ │ │ │ │ │ │ │ │ │ tools: │ │ tools: │ │ tools: │ │ -│ │ coding │ │ minimal │ │ full │ │ +│ │ allow:fs │ │ deny:* │ │ allow:* │ │ │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │ │ │ │ │ └─────────┼────────────────┼────────────────┼─────────────────────┘ @@ -296,7 +258,7 @@ Each Agent's Profile can define its own tools configuration in `config.json`: ```json { "tools": { - "profile": "coding", + "allow": ["group:fs", "group:runtime"], "deny": ["exec"] }, "provider": "anthropic", @@ -305,28 +267,3 @@ Each Agent's Profile can define its own tools configuration in `config.json`: ``` See [Profile README](../profile/README.md) for full documentation. - -### Config Priority - -When both Profile config and CLI options are provided: - -1. **Profile `config.json`** - Base configuration -2. **CLI options** - Override/extend profile settings - -```bash -# Profile has tools.profile = "coding" -# CLI adds --tools-deny exec -# Result: coding profile without exec tool -multica run --profile my-agent --tools-deny exec "list files" -``` - -## Future Tools - -The following tools are planned for future implementation: - -- **Browser** - Simplified web automation (screenshot, click, type) -- **Session Management** - `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` -- **Image** - Image generation and manipulation -- **Cron** - Scheduled task execution -- **Message** - Inter-agent communication -- **Canvas** - Visual output generation diff --git a/src/agent/tools/README.zh-CN.md b/src/agent/tools/README.zh-CN.md index 5ac1a99e..80d84815 100644 --- a/src/agent/tools/README.zh-CN.md +++ b/src/agent/tools/README.zh-CN.md @@ -19,28 +19,22 @@ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ 4 层策略过滤器 │ +│ 3 层策略过滤器 │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ -│ │ 第 1 层: Profile │ │ -│ │ 基础工具集: minimal | coding | web | full │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ 第 2 层: 全局 Allow/Deny │ │ +│ │ 第 1 层: 全局 Allow/Deny │ │ │ │ 通过 CLI 或配置文件进行用户自定义 │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────────────────────────────┐ │ -│ │ 第 3 层: Provider 特定规则 │ │ +│ │ 第 2 层: Provider 特定规则 │ │ │ │ 不同 LLM Provider 有不同的规则 │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────────────────────────────┐ │ -│ │ 第 4 层: Subagent 限制 │ │ +│ │ 第 3 层: Subagent 限制 │ │ │ │ 子 Agent 的工具访问受限 │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ @@ -55,20 +49,20 @@ ## 可用工具 -| 工具 | 名称 | 描述 | -| ------------- | --------------- | --------------------------------------------- | -| Read | `read` | 读取文件内容 | -| Write | `write` | 写入文件内容 | -| Edit | `edit` | 编辑现有文件 | -| Glob | `glob` | 按模式查找文件 | -| Exec | `exec` | 执行 Shell 命令 | -| Process | `process` | 管理长时间运行的进程 | -| Web Fetch | `web_fetch` | 从 URL 获取并提取内容 | -| Web Search | `web_search` | 搜索网络(需要 API Key) | -| Memory Get | `memory_get` | 从持久化内存中获取值 | -| Memory Set | `memory_set` | 向持久化内存中存储值 | -| Memory Delete | `memory_delete` | 从持久化内存中删除值 | -| Memory List | `memory_list` | 列出持久化内存中的所有键 | +| 工具 | 名称 | 描述 | +| ------------- | --------------- | ------------------------ | +| Read | `read` | 读取文件内容 | +| Write | `write` | 写入文件内容 | +| Edit | `edit` | 编辑现有文件 | +| Glob | `glob` | 按模式查找文件 | +| Exec | `exec` | 执行 Shell 命令 | +| Process | `process` | 管理长时间运行的进程 | +| Web Fetch | `web_fetch` | 从 URL 获取并提取内容 | +| Web Search | `web_search` | 搜索网络(需要 API Key) | +| Memory Get | `memory_get` | 从持久化内存中获取值 | +| Memory Set | `memory_set` | 向持久化内存中存储值 | +| Memory Delete | `memory_delete` | 从持久化内存中删除值 | +| Memory List | `memory_list` | 列出持久化内存中的所有键 | > **注意**: Memory 工具需要指定 `profileId`。数据存储在 Profile 的 memory 目录中。 @@ -76,24 +70,13 @@ 工具组提供了一次性允许/禁止多个工具的快捷方式: -| 组 | 工具 | -| --------------- | ------------------------------------------------- | -| `group:fs` | read, write, edit, glob | -| `group:runtime` | exec, process | -| `group:web` | web_search, web_fetch | -| `group:memory` | memory_get, memory_set, memory_delete, memory_list| -| `group:core` | 以上所有(不包括 memory) | - -## 工具配置文件 - -配置文件是为常见用例预定义的工具集: - -| Profile | 描述 | 工具 | -| --------- | ------------------- | ---------------------------------- | -| `minimal` | 无工具(仅聊天) | 无 | -| `coding` | 文件系统 + 执行 | group:fs, group:runtime | -| `web` | 编码 + 网络访问 | group:fs, group:runtime, group:web | -| `full` | 无限制 | 所有工具 | +| 组 | 工具 | +| --------------- | -------------------------------------------------- | +| `group:fs` | read, write, edit, glob | +| `group:runtime` | exec, process | +| `group:web` | web_search, web_fetch | +| `group:memory` | memory_get, memory_set, memory_delete, memory_list | +| `group:core` | 以上所有(不包括 memory) | ## 使用方法 @@ -102,11 +85,8 @@ 所有命令使用统一的 `multica` CLI(开发时使用 `pnpm multica`)。 ```bash -# 使用特定配置文件 -multica run --tools-profile coding "list files" - -# 最小配置文件 + 允许特定工具 -multica run --tools-profile minimal --tools-allow exec "run ls" +# 只允许特定工具 +multica run --tools-allow group:fs,group:runtime "list files" # 禁止特定工具 multica run --tools-deny exec,process "read file.txt" @@ -122,14 +102,11 @@ import { Agent } from './runner.js'; const agent = new Agent({ tools: { - // 第 1 层: 基础配置文件 - profile: 'coding', + // 第 1 层: 全局 allow/deny + allow: ['group:fs', 'group:runtime', 'web_fetch'], + deny: ['exec'], - // 第 2 层: 全局自定义 - allow: ['web_fetch'], // 在 coding 配置文件基础上添加 web_fetch - deny: ['exec'], // 但禁止 exec - - // 第 3 层: Provider 特定规则 + // 第 2 层: Provider 特定规则 byProvider: { google: { deny: ['exec', 'process'], // Google 模型不能使用运行时工具 @@ -137,7 +114,7 @@ const agent = new Agent({ }, }, - // 第 4 层: Subagent 模式 + // 第 3 层: Subagent 模式 isSubagent: false, }); ``` @@ -150,43 +127,28 @@ const agent = new Agent({ # 列出所有可用工具 multica tools list -# 列出应用配置文件后的工具 -multica tools list --profile coding +# 列出带有允许规则的工具 +multica tools list --allow group:fs,group:runtime # 列出带有禁止规则的工具 -multica tools list --profile coding --deny exec +multica tools list --deny exec # 显示所有工具组 multica tools groups - -# 显示所有配置文件 -multica tools profiles ``` ## 策略系统详情 -### 第 1 层: Profile +### 第 1 层: 全局 Allow/Deny -配置文件决定了可用工具的基础集合。如果未指定,则所有工具都可用。 +用户指定的 allow/deny 列表: -```typescript -// 在 groups.ts 中 -export const TOOL_PROFILES = { - minimal: { allow: [] }, // 无工具 - coding: { allow: ['group:fs', 'group:runtime'] }, // 文件系统 + 执行 - web: { allow: ['group:fs', 'group:runtime', 'group:web'] }, // + 网络 - full: {}, // 无限制 -}; -``` +- `allow`: 只有这些工具可用(支持 group:\* 语法) +- `deny`: 这些工具被阻止(优先于 allow) -### 第 2 层: 全局 Allow/Deny +如果未指定 `allow` 列表,默认所有工具都可用。 -用户指定的 allow/deny 列表,用于修改配置文件的工具集: - -- `allow`: 只有这些工具可用(在配置文件基础上添加) -- `deny`: 这些工具被阻止(优先于 allow) - -### 第 3 层: Provider 特定规则 +### 第 2 层: Provider 特定规则 不同的 LLM Provider 可能有不同的能力或限制: @@ -199,7 +161,7 @@ export const TOOL_PROFILES = { } ``` -### 第 4 层: Subagent 限制 +### 第 3 层: Subagent 限制 当 `isSubagent: true` 时,会应用额外的限制,防止子 Agent 访问敏感工具(如会话管理)。 @@ -280,7 +242,7 @@ pnpm test src/agent/tools/policy.test.ts │ │ coder │ │ reviewer │ │ devops │ │ │ │ │ │ │ │ │ │ │ │ tools: │ │ tools: │ │ tools: │ │ -│ │ coding │ │ minimal │ │ full │ │ +│ │ allow:fs │ │ deny:* │ │ allow:* │ │ │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │ │ │ │ │ └─────────┼────────────────┼────────────────┼─────────────────────┘ @@ -296,7 +258,7 @@ pnpm test src/agent/tools/policy.test.ts ```json { "tools": { - "profile": "coding", + "allow": ["group:fs", "group:runtime"], "deny": ["exec"] }, "provider": "anthropic", @@ -305,28 +267,3 @@ pnpm test src/agent/tools/policy.test.ts ``` 详见 [Profile README](../profile/README.md)。 - -### 配置优先级 - -当同时提供 Profile 配置和 CLI 选项时: - -1. **Profile `config.json`** - 基础配置 -2. **CLI 选项** - 覆盖/扩展 Profile 设置 - -```bash -# Profile 有 tools.profile = "coding" -# CLI 添加 --tools-deny exec -# 结果: coding 配置文件但没有 exec 工具 -multica run --profile my-agent --tools-deny exec "list files" -``` - -## 未来工具 - -以下工具计划在未来实现: - -- **Browser** - 简化的网页自动化(截图、点击、输入) -- **Session Management** - `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` -- **Image** - 图像生成和处理 -- **Cron** - 定时任务执行 -- **Message** - Agent 间通信 -- **Canvas** - 可视化输出生成 diff --git a/src/agent/tools/groups.ts b/src/agent/tools/groups.ts index 1e9edf6c..dde6b5a0 100644 --- a/src/agent/tools/groups.ts +++ b/src/agent/tools/groups.ts @@ -1,12 +1,10 @@ /** - * Tool groups and profiles for policy-based filtering. + * Tool groups for policy-based filtering. * * Groups provide shortcuts for allowing/denying multiple tools at once. - * Profiles are predefined tool sets for common use cases. + * Use "group:name" in allow/deny lists. */ -export type ToolProfileId = "minimal" | "coding" | "web" | "full"; - /** * Tool name aliases for compatibility. * Maps alternative names to canonical tool names. @@ -51,29 +49,6 @@ export const TOOL_GROUPS: Record = { ], }; -/** - * Tool profiles - predefined tool sets. - */ -export const TOOL_PROFILES: Record = { - // Minimal: no tools (useful for chat-only agents) - minimal: { - allow: [], - }, - - // Coding: file system + execution (default for coding tasks) - coding: { - allow: ["group:fs", "group:runtime"], - }, - - // Web: coding + web access - web: { - allow: ["group:fs", "group:runtime", "group:web"], - }, - - // Full: no restrictions - full: {}, -}; - /** * Default tools denied for subagents. * Subagents should not have access to session management or system tools. @@ -118,23 +93,3 @@ export function expandToolGroups(list?: string[]): string[] { return Array.from(new Set(expanded)); } - -/** - * Get the policy for a profile. - */ -export function getProfilePolicy( - profile?: ToolProfileId, -): { allow?: string[]; deny?: string[] } | undefined { - if (!profile) return undefined; - const resolved = TOOL_PROFILES[profile]; - if (!resolved) return undefined; - if (!resolved.allow && !resolved.deny) return undefined; - const result: { allow?: string[]; deny?: string[] } = {}; - if (resolved.allow) { - result.allow = [...resolved.allow]; - } - if (resolved.deny) { - result.deny = [...resolved.deny]; - } - return result; -} diff --git a/src/agent/tools/index.ts b/src/agent/tools/index.ts index 1e6f6334..70b616f4 100644 --- a/src/agent/tools/index.ts +++ b/src/agent/tools/index.ts @@ -8,17 +8,14 @@ export { createProcessTool } from "./process.js"; export { createGlobTool } from "./glob.js"; export { createWebFetchTool, createWebSearchTool } from "./web/index.js"; -// Tool groups and profiles +// Tool groups export { - type ToolProfileId, TOOL_NAME_ALIASES, TOOL_GROUPS, - TOOL_PROFILES, DEFAULT_SUBAGENT_TOOL_DENY, normalizeToolName, normalizeToolList, expandToolGroups, - getProfilePolicy, } from "./groups.js"; // Tool policy system diff --git a/src/agent/tools/policy.test.ts b/src/agent/tools/policy.test.ts index e0902708..3cfa5276 100644 --- a/src/agent/tools/policy.test.ts +++ b/src/agent/tools/policy.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { filterTools } from "./policy.js"; -import { TOOL_PROFILES, expandToolGroups } from "./groups.js"; +import { expandToolGroups } from "./groups.js"; // Mock tools for testing const mockTools = [ @@ -36,58 +36,12 @@ describe("tool groups", () => { }); }); -describe("tool profiles", () => { - it("minimal has empty allow", () => { - expect(TOOL_PROFILES.minimal.allow).toEqual([]); - }); - - it("coding has fs and runtime", () => { - expect(TOOL_PROFILES.coding.allow).toEqual(["group:fs", "group:runtime"]); - }); - - it("full has no restrictions", () => { - expect(TOOL_PROFILES.full.allow).toBeUndefined(); - expect(TOOL_PROFILES.full.deny).toBeUndefined(); - }); -}); - describe("filterTools", () => { it("no config returns all tools", () => { const filtered = filterTools(mockTools, {}); expect(filtered.length).toBe(mockTools.length); }); - it("minimal profile returns no tools", () => { - const filtered = filterTools(mockTools, { config: { profile: "minimal" } }); - expect(filtered.length).toBe(0); - }); - - it("coding profile returns fs and runtime", () => { - const filtered = filterTools(mockTools, { config: { profile: "coding" } }); - const names = filtered.map((t) => t.name).sort(); - expect(names).toEqual(["edit", "exec", "glob", "process", "read", "write"]); - }); - - it("web profile returns all", () => { - const filtered = filterTools(mockTools, { config: { profile: "web" } }); - const names = filtered.map((t) => t.name).sort(); - expect(names).toEqual([ - "edit", - "exec", - "glob", - "process", - "read", - "web_fetch", - "web_search", - "write", - ]); - }); - - it("full profile returns all tools", () => { - const filtered = filterTools(mockTools, { config: { profile: "full" } }); - expect(filtered.length).toBe(mockTools.length); - }); - it("deny specific tool", () => { const filtered = filterTools(mockTools, { config: { deny: ["exec"] } }); const names = filtered.map((t) => t.name); @@ -110,6 +64,22 @@ describe("filterTools", () => { const names = filtered.map((t) => t.name).sort(); expect(names).toEqual(["read", "write"]); }); + + it("allow with group:* syntax", () => { + const filtered = filterTools(mockTools, { + config: { allow: ["group:fs", "group:runtime"] }, + }); + const names = filtered.map((t) => t.name).sort(); + expect(names).toEqual(["edit", "exec", "glob", "process", "read", "write"]); + }); + + it("deny with group:* syntax", () => { + const filtered = filterTools(mockTools, { + config: { deny: ["group:web"] }, + }); + const names = filtered.map((t) => t.name).sort(); + expect(names).toEqual(["edit", "exec", "glob", "process", "read", "write"]); + }); }); describe("provider-specific filtering", () => { @@ -149,10 +119,10 @@ describe("subagent restrictions", () => { }); describe("combined filtering", () => { - it("profile + deny", () => { + it("allow + deny", () => { const filtered = filterTools(mockTools, { config: { - profile: "coding", + allow: ["group:fs", "group:runtime"], deny: ["exec"], }, }); @@ -160,10 +130,10 @@ describe("combined filtering", () => { expect(names).toEqual(["edit", "glob", "process", "read", "write"]); }); - it("profile + provider deny", () => { + it("allow + provider deny", () => { const filtered = filterTools(mockTools, { config: { - profile: "web", + allow: ["group:fs", "group:runtime", "group:web"], byProvider: { google: { deny: ["exec"] }, }, diff --git a/src/agent/tools/policy.ts b/src/agent/tools/policy.ts index 5b8c2fc0..3e1468fe 100644 --- a/src/agent/tools/policy.ts +++ b/src/agent/tools/policy.ts @@ -1,18 +1,15 @@ /** * Tool policy system for filtering tools based on configuration. * - * Supports 4 layers of filtering: - * 1. Profile - base tool set (minimal/coding/web/full) - * 2. Global allow/deny - user customization - * 3. Provider-specific - different rules for different LLM providers - * 4. Subagent restrictions - limited tools for spawned agents + * Supports 3 layers of filtering: + * 1. Global allow/deny - user customization + * 2. Provider-specific - different rules for different LLM providers + * 3. Subagent restrictions - limited tools for spawned agents */ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { - type ToolProfileId, expandToolGroups, - getProfilePolicy, normalizeToolName, DEFAULT_SUBAGENT_TOOL_DENY, } from "./groups.js"; @@ -31,11 +28,9 @@ export interface ToolPolicy { * Full tool configuration from config file. */ export interface ToolsConfig { - /** Base profile (minimal/coding/web/full) */ - profile?: ToolProfileId; - /** Additional tools to allow */ + /** Tools to allow (supports group:* syntax) */ allow?: string[]; - /** Tools to deny */ + /** Tools to deny (takes precedence over allow) */ deny?: string[]; /** Provider-specific overrides */ byProvider?: Record; @@ -191,12 +186,11 @@ export interface FilterToolsOptions { } /** - * Filter tools through the 4-layer policy system. + * Filter tools through the 3-layer policy system. * - * Layer 1: Profile (base tool set) - * Layer 2: Global allow/deny - * Layer 3: Provider-specific - * Layer 4: Subagent restrictions + * Layer 1: Global allow/deny + * Layer 2: Provider-specific + * Layer 3: Subagent restrictions */ export function filterTools( tools: AgentTool[], @@ -206,15 +200,7 @@ export function filterTools( let filtered = tools; - // Layer 1: Profile - if (config?.profile) { - const profilePolicy = getProfilePolicy(config.profile); - if (profilePolicy) { - filtered = filterToolsByPolicy(filtered, profilePolicy); - } - } - - // Layer 2: Global allow/deny + // Layer 1: Global allow/deny if (config?.allow || config?.deny) { const globalPolicy: ToolPolicy = {}; if (config.allow) { @@ -226,7 +212,7 @@ export function filterTools( filtered = filterToolsByPolicy(filtered, globalPolicy); } - // Layer 3: Provider-specific + // Layer 2: Provider-specific if (provider && config?.byProvider) { const providerPolicy = resolveProviderPolicy(config.byProvider, provider); if (providerPolicy) { @@ -234,7 +220,7 @@ export function filterTools( } } - // Layer 4: Subagent restrictions + // Layer 3: Subagent restrictions if (isSubagent) { const subagentPolicy = getSubagentPolicy(); filtered = filterToolsByPolicy(filtered, subagentPolicy); @@ -246,7 +232,6 @@ export function filterTools( /** * Merge two ToolsConfig objects. * The override config takes precedence: - * - profile: override wins if set * - allow: union of both * - deny: union of both * - byProvider: deep merge with override taking precedence @@ -261,12 +246,6 @@ export function mergeToolsConfig( const result: ToolsConfig = {}; - // profile: override wins - const profile = override.profile ?? base.profile; - if (profile) { - result.profile = profile; - } - // allow: union const allow = mergeAllow(base.allow, override.allow); if (allow) { @@ -321,15 +300,7 @@ export function wouldToolBeAllowed( ): boolean { const { config, provider, isSubagent } = options; - // Layer 1: Profile - if (config?.profile) { - const profilePolicy = getProfilePolicy(config.profile); - if (profilePolicy && !isToolAllowed(toolName, profilePolicy)) { - return false; - } - } - - // Layer 2: Global allow/deny + // Layer 1: Global allow/deny if (config?.allow || config?.deny) { const globalPolicy: ToolPolicy = {}; if (config.allow) { @@ -343,7 +314,7 @@ export function wouldToolBeAllowed( } } - // Layer 3: Provider-specific + // Layer 2: Provider-specific if (provider && config?.byProvider) { const providerPolicy = resolveProviderPolicy(config.byProvider, provider); if (providerPolicy && !isToolAllowed(toolName, providerPolicy)) { @@ -351,7 +322,7 @@ export function wouldToolBeAllowed( } } - // Layer 4: Subagent restrictions + // Layer 3: Subagent restrictions if (isSubagent) { const subagentPolicy = getSubagentPolicy(); if (!isToolAllowed(toolName, subagentPolicy)) { From a3e639f8f598b6103a7e55155fde94adc2992763 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Thu, 5 Feb 2026 03:14:44 +0800 Subject: [PATCH 16/30] refactor(cli): make desktop app the default dev target - Change `multica dev` default from gateway+console+web to desktop app - Remove console from dev command options (use embedded Hub in desktop) - Update package.json scripts to reflect new defaults - Update README.md and CLAUDE.md architecture documentation The desktop app has an embedded Hub, so no separate console/gateway is needed for local development. Gateway is now optional, only needed for remote client access. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 29 +++++++++--------- README.md | 55 ++++++++++++++++++++--------------- package.json | 6 ++-- src/agent/cli/commands/dev.ts | 53 ++++++++++++--------------------- 4 files changed, 70 insertions(+), 73 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 32d8a9d7..267369b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Super Multica is a distributed AI agent framework with a monorepo architecture. It includes an agent engine with multi-provider LLM support, a WebSocket gateway, a console hub for multi-agent coordination, and frontend apps (Next.js web, Electron desktop). +Super Multica is a distributed AI agent framework with a monorepo architecture. It includes an agent engine with multi-provider LLM support, an Electron desktop app with embedded Hub, a WebSocket gateway for remote access, and a Next.js web app. ## Monorepo Structure -- **`src/`** — Core modules (agent engine, gateway, console, client, shared types) +- **`src/`** — Core modules (agent engine, gateway, hub, shared types) +- **`apps/desktop`** — Electron + Vite + React desktop app (`@multica/desktop`) — **primary development target** - **`apps/web`** — Next.js 16 web app (`@multica/web`, port 3001) -- **`apps/desktop`** — Electron + Vite + React desktop app (`@multica/desktop`) - **`packages/ui`** — Shared UI component library (`@multica/ui`, Shadcn/Tailwind CSS v4) - **`packages/sdk`** — Gateway client SDK (`@multica/sdk`, Socket.io) - **`packages/store`** — Zustand state management (`@multica/store`) @@ -31,15 +31,14 @@ multica profile list # List profiles multica skills list # List skills multica tools list # List tools multica credentials init # Initialize credentials -multica dev # Start all dev services +multica dev # Start desktop app (default) multica help # Show help # Development servers -multica dev # All services (gateway:3000, console:4000, web:3001) -multica dev gateway # WebSocket gateway only -multica dev console # NestJS console with agent +multica dev # Desktop app (default, recommended) +multica dev gateway # WebSocket gateway only (for remote clients) multica dev web # Next.js web app -multica dev desktop # Electron desktop app +multica dev all # Gateway + web app # Build (turbo-orchestrated) pnpm build @@ -56,18 +55,22 @@ pnpm test:coverage # With v8 coverage ## Architecture ``` -Frontend (web:3001 / desktop) +Desktop App (standalone, recommended) + └─ Hub (embedded) + └─ Agent Engine (LLM runner, sessions, skills, tools) + └─ (Optional) Gateway connection for remote access + +Web App (requires Gateway) → @multica/sdk (GatewayClient, Socket.io) → Gateway (NestJS, WebSocket, port 3000) - → Console Hub (multi-agent coordination) - → Agent Engine (LLM runner, sessions, skills, tools) + → Hub + Agent Engine ``` **Agent Engine** (`src/agent/`): Orchestrates LLM interactions with multi-provider support (OpenAI, Anthropic, DeepSeek, Kimi, Groq, Mistral, Google, Together). Features session management (JSONL-based, UUIDv7 IDs), profile system (`~/.super-multica/agent-profiles/`), modular skills with hot-reload, and token-aware context window guards (compaction modes: tokens, count, summary). Unified CLI in `src/agent/cli/index.ts` with subcommands in `src/agent/cli/commands/`. -**Gateway** (`src/gateway/`): NestJS WebSocket server with Socket.io for real-time message passing, RPC request/response, and streaming. +**Hub** (`src/hub/`): Manages agents and communication channels. Embedded in desktop app, or runs standalone for web clients. -**Console** (`src/console/`): NestJS hub for multi-agent coordination with a web dashboard. +**Gateway** (`src/gateway/`): NestJS WebSocket server with Socket.io for remote client access, message routing, and device verification. ## Tech Stack & Config diff --git a/README.md b/README.md index 5aee5c5e..c6fd6d28 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,18 @@ src/ │ ├── skills/ # Modular skill system │ └── tools/ # Agent tools │ └── web/ # Web fetch and search tools -├── gateway/ # WebSocket gateway for distributed communication -├── hub/ # Multi-agent coordination hub -├── client/ # Client library -├── console/ # NestJS console application -└── shared/ # Shared types and gateway SDK - └── gateway-sdk/ # Gateway client SDK +├── gateway/ # WebSocket gateway for remote access +├── hub/ # Agent coordination hub +└── shared/ # Shared types apps/ +├── desktop/ # Electron desktop app (recommended) └── web/ # Next.js web application packages/ -└── sdk/ # SDK package for external use +├── sdk/ # Gateway client SDK +├── store/ # Zustand state management +└── ui/ # Shared UI components skills/ # Bundled skills (commit, code-review) ``` @@ -85,9 +85,8 @@ Example `skills.env.json5` (dynamic keys): Start services directly (no `source .env`): ```bash -multica dev console -multica run "hello" -multica dev gateway +multica dev # Start desktop app +multica run "hello" # Run CLI mode ``` Optional overrides: @@ -194,10 +193,10 @@ multica chat --profile my-agent multica run --thinking high "solve this complex problem" # Development servers -multica dev # Start all services -multica dev gateway # Gateway only (:3000) -multica dev console # Console only (:4000) +multica dev # Start desktop app (default) +multica dev gateway # Gateway only (:3000) - for remote clients multica dev web # Web app only (:3001) +multica dev all # Start gateway + web # Help multica help @@ -383,23 +382,33 @@ web_search({ }) ``` -## Distributed Architecture +## Architecture + +### Desktop App (Recommended) + +The Electron desktop app runs a standalone Hub with embedded Agent Engine: + +- **No Gateway required** for local development +- Direct IPC communication for optimal performance +- QR code pairing for mobile remote access +- Optional Gateway connection for web/remote clients ### Gateway -The WebSocket gateway enables distributed multi-agent communication: +The WebSocket gateway enables remote client access: -- Real-time message passing between agents +- Real-time message routing between clients and Hub - Streaming support for long-running operations - RPC-style request/response patterns +- Device verification and authentication ### Hub -The Hub manages multiple agents and gateway connections: +The Hub manages agents and communication: - Agent lifecycle management -- Communication channel coordination -- Device identification and tracking +- Multi-subscriber event distribution +- Device whitelist and token-based verification ## Scripts @@ -418,11 +427,11 @@ The Hub manages multiple agents and gateway connections: ### Development (shortcuts) -- `pnpm dev` - Run full stack (gateway + console + web) -- `pnpm dev:gateway` - Run gateway only -- `pnpm dev:console` - Run console only -- `pnpm dev:web` - Run web app only +- `pnpm dev` - Run desktop app (default, recommended) - `pnpm dev:desktop` - Run desktop app +- `pnpm dev:gateway` - Run gateway only (for remote clients) +- `pnpm dev:web` - Run web app only +- `pnpm dev:all` - Run gateway + web ### Build & Test diff --git a/package.json b/package.json index 1396639c..38c7ec1a 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "multica": "tsx src/agent/cli/index.ts", "mu": "tsx src/agent/cli/index.ts", "dev": "tsx src/agent/cli/index.ts dev", - "dev:gateway": "tsx src/agent/cli/index.ts dev gateway", - "dev:console": "tsx src/agent/cli/index.ts dev console", - "dev:web": "tsx src/agent/cli/index.ts dev web", "dev:desktop": "tsx src/agent/cli/index.ts dev desktop", + "dev:gateway": "tsx src/agent/cli/index.ts dev gateway", + "dev:web": "tsx src/agent/cli/index.ts dev web", + "dev:all": "tsx src/agent/cli/index.ts dev all", "build": "turbo build", "build:sdk": "pnpm --filter @multica/sdk build", "build:cli": "node scripts/build-cli.js", diff --git a/src/agent/cli/commands/dev.ts b/src/agent/cli/commands/dev.ts index 88d608ca..edd06cd6 100644 --- a/src/agent/cli/commands/dev.ts +++ b/src/agent/cli/commands/dev.ts @@ -2,44 +2,46 @@ * Dev command - Start development servers * * Usage: - * multica dev Start all services (gateway + console + web) - * multica dev gateway Start gateway only (:3000) - * multica dev console Start console only (:4000) + * multica dev Start desktop app (with embedded Hub) + * multica dev gateway Start gateway only (:3000) - for remote clients * multica dev web Start web app only (:3001) - * multica dev desktop Start desktop app + * multica dev all Start all services (gateway + web) */ import { spawn } from "node:child_process"; import { cyan, yellow, green, dim, red } from "../colors.js"; -type Service = "all" | "gateway" | "console" | "web" | "desktop" | "help"; +type Service = "all" | "gateway" | "web" | "desktop" | "help"; function printHelp() { console.log(` ${cyan("Usage:")} multica dev [service] ${cyan("Services:")} - ${yellow("(default)")} Start all services (gateway + console + web) - ${yellow("gateway")} Start Gateway server (:3000) - ${yellow("console")} Start Console server (:4000) + ${yellow("(default)")} Start Desktop app (with embedded Hub) + ${yellow("gateway")} Start Gateway server (:3000) - for remote clients ${yellow("web")} Start Web app (:3001) - ${yellow("desktop")} Start Desktop app + ${yellow("all")} Start all services (gateway + web) ${yellow("help")} Show this help ${cyan("Architecture:")} - Frontend (web:3001 / desktop) + Desktop App (standalone) + └─ Embedded Hub + Agent Engine + └─ (Optional) Gateway connection for remote access + + Web App (requires Gateway) → Gateway (WebSocket, :3000) - → Console Hub (multi-agent coordination, :4000) - → Agent Engine + → Hub + Agent Engine ${cyan("Examples:")} - ${dim("# Start all services")} + ${dim("# Start desktop app (recommended for local development)")} multica dev - ${dim("# Start only the gateway")} + ${dim("# Start desktop with remote Gateway for mobile access")} + GATEWAY_URL=http://localhost:3000 multica dev & multica dev gateway - ${dim("# Start web and gateway separately")} + ${dim("# Start web app with gateway")} multica dev gateway & multica dev web `); @@ -52,7 +54,7 @@ interface DevOptions { function parseArgs(argv: string[]): DevOptions { const args = [...argv]; - let service: Service = "all"; + let service: Service = "desktop"; let watch = true; while (args.length > 0) { @@ -68,7 +70,7 @@ function parseArgs(argv: string[]): DevOptions { } // Service name - if (["gateway", "console", "web", "desktop", "all", "help"].includes(arg)) { + if (["gateway", "web", "desktop", "all", "help"].includes(arg)) { service = arg as Service; } } @@ -105,14 +107,6 @@ async function startGateway(watch: boolean) { }); } -async function startConsole(watch: boolean) { - const watchFlag = watch ? "--watch" : ""; - return runCommand("tsx", [watchFlag, "src/console/main.ts"].filter(Boolean), { - name: "console", - color: "\x1b[33m", // yellow - }); -} - async function startWeb() { return runCommand("pnpm", ["--filter", "@multica/web", "dev"], { name: "web", @@ -130,20 +124,17 @@ async function startDesktop() { async function startAll(watch: boolean) { console.log(`\n${cyan("Starting all services...")}\n`); console.log(` ${"\x1b[34m"}Gateway${"\x1b[0m"} → http://localhost:3000`); - console.log(` ${"\x1b[33m"}Console${"\x1b[0m"} → http://localhost:4000`); console.log(` ${"\x1b[32m"}Web${"\x1b[0m"} → http://localhost:3001`); console.log(""); // Start all services const gateway = await startGateway(watch); - const console_ = await startConsole(watch); const web = await startWeb(); // Handle Ctrl+C const cleanup = () => { console.log(`\n${dim("Stopping all services...")}`); gateway.kill(); - console_.kill(); web.kill(); process.exit(0); }; @@ -154,7 +145,6 @@ async function startAll(watch: boolean) { // Wait for all to exit await Promise.all([ new Promise((resolve) => gateway.on("exit", resolve)), - new Promise((resolve) => console_.on("exit", resolve)), new Promise((resolve) => web.on("exit", resolve)), ]); } @@ -168,11 +158,6 @@ export async function devCommand(args: string[]): Promise { await startGateway(opts.watch); break; - case "console": - console.log(`\n${cyan("Starting Console...")} → http://localhost:4000\n`); - await startConsole(opts.watch); - break; - case "web": console.log(`\n${cyan("Starting Web App...")} → http://localhost:3001\n`); await startWeb(); From cdc64f9c837bfc32be6089bc611a84d001eb45d3 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Thu, 5 Feb 2026 03:28:33 +0800 Subject: [PATCH 17/30] feat(profile): enforce file-based memory with stronger guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update workspace.md template to prevent agents from claiming to "remember" things without actually editing files. The new guidance: - Uses "No Mental Notes!" as a strong warning - Lists specific trigger phrases (记住, Remember, I prefer, etc.) - Maps each trigger to the appropriate file to edit - Explicitly forbids saying "I'll remember" without file edits This addresses the issue where agents would acknowledge user preferences verbally but not persist them to profile files. Co-Authored-By: Claude Opus 4.5 --- src/agent/profile/templates.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/agent/profile/templates.ts b/src/agent/profile/templates.ts index 28c2fec2..ffbb2d59 100644 --- a/src/agent/profile/templates.ts +++ b/src/agent/profile/templates.ts @@ -92,14 +92,26 @@ You wake up fresh each session. These files are your continuity: Capture what matters. Decisions, context, things to remember. -### Write It Down +### 📝 Write It Down - No "Mental Notes"! -- Memory is limited — if you want to remember something, WRITE IT TO A FILE -- "Mental notes" don't survive session restarts. Files do. -- When you learn something about the user → update \`USER.md\` -- When you learn a lesson → update \`MEMORY.md\` +⚠️ **CRITICAL**: You CANNOT "remember" things mentally. Your memory resets each session. If you don't write it to a file, it's gone. + +**Which file to edit:** +- \`user.md\` — About your human: name, preferences, habits, context, anything personal +- \`memory.md\` — Your learnings: decisions made, lessons learned, important context +- \`workspace.md\` — Your rules: conventions, workflows, how you should operate +- \`soul.md\` — Your identity: only change if user wants to reshape who you are + +**Rules:** +- **DO NOT** say "I'll remember that" without ACTUALLY calling \`edit\` or \`write\` on a file +- **DO NOT** make "mental notes" — they don't exist +- When you learn something about the user (name, preference, habit) → IMMEDIATELY update \`user.md\` +- When you learn a lesson, make a decision, or gain context → IMMEDIATELY update \`memory.md\` +- When you discover a better workflow or convention → update \`workspace.md\` - When you make a mistake → document it so future-you doesn't repeat it +**Text > Brain** 📝 + ## Safety - Don't exfiltrate private data. Ever. From 71cc8aee405f73729d231b45e52f939d30b7f8dd Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Thu, 5 Feb 2026 03:41:45 +0800 Subject: [PATCH 18/30] fix(desktop): add sessions_spawn to tool groups The sessions_spawn tool was not showing in the desktop tools UI because TOOL_GROUPS and ALL_KNOWN_TOOLS were missing the subagent group. Co-Authored-By: Claude Opus 4.5 --- apps/desktop/electron/ipc/agent.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/electron/ipc/agent.ts b/apps/desktop/electron/ipc/agent.ts index a358b88a..61622286 100644 --- a/apps/desktop/electron/ipc/agent.ts +++ b/apps/desktop/electron/ipc/agent.ts @@ -13,6 +13,7 @@ const TOOL_GROUPS: Record = { 'group:runtime': ['exec', 'process'], 'group:web': ['web_search', 'web_fetch'], 'group:memory': ['memory_get', 'memory_set', 'memory_delete', 'memory_list'], + 'group:subagent': ['sessions_spawn'], } // All known tool names (for display when agent not available) @@ -21,6 +22,7 @@ const ALL_KNOWN_TOOLS = [ ...TOOL_GROUPS['group:runtime'], ...TOOL_GROUPS['group:web'], ...TOOL_GROUPS['group:memory'], + ...TOOL_GROUPS['group:subagent'], ] /** From fc6c3e30b337f16ce47fa23e24864064fa592011 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Thu, 5 Feb 2026 04:01:30 +0800 Subject: [PATCH 19/30] chore(desktop): remove coming soon label from remote agent button Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/pages/home.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/desktop/src/pages/home.tsx b/apps/desktop/src/pages/home.tsx index 56396e53..9019a3ae 100644 --- a/apps/desktop/src/pages/home.tsx +++ b/apps/desktop/src/pages/home.tsx @@ -357,11 +357,9 @@ export default function HomePage() { variant="ghost" size="sm" className="text-muted-foreground gap-1.5" - disabled > Connect to Remote Agent - (Coming soon)
From b74a5ea1a7f16fe1f0e3cd987218acb439a62fdc Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 5 Feb 2026 07:56:11 +0800 Subject: [PATCH 20/30] feat(ui): replace ChatInput textarea with Tiptap editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the plain