diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 1b915d0d..157baeab 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -7,7 +7,7 @@ */ import { join } from "node:path"; -import { Agent } from "@multica/core"; +import { Agent, Hub, listSubagentRuns } from "@multica/core"; import type { AgentOptions } from "@multica/core"; import type { ToolsConfig } from "@multica/core"; import { DATA_DIR } from "@multica/utils"; @@ -192,35 +192,90 @@ export async function runCommand(args: string[]): Promise { const enableRunLog = opts.runLog || !!process.env.MULTICA_RUN_LOG; - const agent = new Agent({ - profileId: opts.profile, - provider: opts.provider, - model: opts.model, - apiKey: opts.apiKey, - baseUrl: opts.baseUrl, - systemPrompt: opts.system, - thinkingLevel: opts.thinking as any, - reasoningMode: opts.reasoning as AgentOptions["reasoningMode"], - cwd: opts.cwd, - sessionId: opts.session, - debug: opts.debug, - enableRunLog, - tools: toolsConfig, - }); + // Initialize Hub to enable full agent capabilities (sub-agents, channels, cron). + // Matches Desktop environment where Hub is always active. + // Gateway connection failures are non-blocking (auto-reconnect with backoff). + const gatewayUrl = process.env.GATEWAY_URL || "http://localhost:3000"; + const hub = new Hub(gatewayUrl); - const sessionDir = join(DATA_DIR, "sessions", agent.sessionId); + try { + const agent = new Agent({ + profileId: opts.profile, + provider: opts.provider, + model: opts.model, + apiKey: opts.apiKey, + baseUrl: opts.baseUrl, + systemPrompt: opts.system, + thinkingLevel: opts.thinking as any, + reasoningMode: opts.reasoning as AgentOptions["reasoningMode"], + cwd: opts.cwd, + sessionId: opts.session, + debug: opts.debug, + enableRunLog, + tools: toolsConfig, + }); - // If it's a newly created session, notify user of sessionId - if (!opts.session) { - console.error(`[session: ${agent.sessionId}]`); - } - if (enableRunLog) { - console.error(`[session-dir: ${sessionDir}]`); - } + const sessionDir = join(DATA_DIR, "sessions", agent.sessionId); - const result = await agent.run(finalPrompt); - if (result.error) { - console.error(`Error: ${result.error}`); - process.exitCode = 1; + // If it's a newly created session, notify user of sessionId + if (!opts.session) { + console.error(`[session: ${agent.sessionId}]`); + } + if (enableRunLog) { + console.error(`[session-dir: ${sessionDir}]`); + } + + const result = await agent.run(finalPrompt); + if (result.error) { + console.error(`Error: ${result.error}`); + process.exitCode = 1; + } + + // Wait for sub-agents to complete and parent to process their results. + // Without this, CLI exits before sub-agent announcements are delivered. + await waitForSubagents(agent); + } finally { + hub.shutdown(); + } +} + +/** + * Wait for any running sub-agents to complete, then output their findings. + * + * In CLI mode, the parent Agent is not registered with the Hub, so the normal + * announce flow (Hub → writeInternal) can't deliver results. Instead, we poll + * the registry and print findings directly once all sub-agents finish. + * + * Max wait: 30 minutes (matches default sub-agent timeout). + */ +async function waitForSubagents(agent: Agent): Promise { + const MAX_WAIT_MS = 30 * 60 * 1000; + const POLL_INTERVAL_MS = 2000; + const start = Date.now(); + + const allRuns = listSubagentRuns(agent.sessionId); + if (allRuns.length === 0) return; + + // Phase 1: Wait for all sub-agent runs to finish + while (Date.now() - start < MAX_WAIT_MS) { + const runs = listSubagentRuns(agent.sessionId); + const running = runs.filter((r) => !r.endedAt); + if (running.length === 0) break; + console.error(dim(`[waiting for ${running.length} sub-agent(s)...]`)); + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } + + // Phase 2: Output sub-agent findings directly (bypasses Hub announce flow) + const completedRuns = listSubagentRuns(agent.sessionId).filter((r) => r.endedAt); + if (completedRuns.length === 0) return; + + console.error(dim(`[${completedRuns.length} sub-agent(s) completed]`)); + + for (const run of completedRuns) { + const displayName = run.label || run.task.slice(0, 60); + const status = run.outcome?.status ?? "unknown"; + const findings = run.findings || "(no output)"; + console.log(`\n--- Sub-agent: ${displayName} [${status}] ---`); + console.log(findings); } } diff --git a/packages/core/src/agent/credentials.ts b/packages/core/src/agent/credentials.ts index 6e6f8363..d93446b9 100644 --- a/packages/core/src/agent/credentials.ts +++ b/packages/core/src/agent/credentials.ts @@ -36,6 +36,7 @@ export type CredentialsConfig = { }; const DEFAULT_CREDENTIALS_PATH = join(DATA_DIR, "credentials.json5"); +const FALLBACK_CREDENTIALS_PATH = join(homedir(), ".super-multica", "credentials.json5"); function expandHome(value: string): string { if (value === "~") return homedir(); @@ -53,9 +54,34 @@ function isTestEnv(): boolean { ); } +/** + * Resolve the credentials file path. + * + * Lookup order: + * 1. SMC_CREDENTIALS_PATH env var (explicit override) + * 2. {DATA_DIR}/credentials.json5 (current data dir, respects SMC_DATA_DIR) + * 3. ~/.super-multica/credentials.json5 (default location fallback — + * allows E2E tests and other custom SMC_DATA_DIR setups to + * share the production credentials) + */ export function getCredentialsPath(): string { - const raw = process.env.SMC_CREDENTIALS_PATH ?? DEFAULT_CREDENTIALS_PATH; - return expandHome(raw); + // Explicit env override — use as-is + if (process.env.SMC_CREDENTIALS_PATH) { + return expandHome(process.env.SMC_CREDENTIALS_PATH); + } + + // Primary: current DATA_DIR + if (existsSync(DEFAULT_CREDENTIALS_PATH)) { + return DEFAULT_CREDENTIALS_PATH; + } + + // Fallback: default ~/.super-multica location when using a custom data dir + if (DEFAULT_CREDENTIALS_PATH !== FALLBACK_CREDENTIALS_PATH && existsSync(FALLBACK_CREDENTIALS_PATH)) { + return FALLBACK_CREDENTIALS_PATH; + } + + // Return primary path even if it doesn't exist (for error messages / creation) + return DEFAULT_CREDENTIALS_PATH; } export class CredentialManager { diff --git a/packages/core/src/agent/run-log.ts b/packages/core/src/agent/run-log.ts index e8e3b446..18bf5c80 100644 --- a/packages/core/src/agent/run-log.ts +++ b/packages/core/src/agent/run-log.ts @@ -25,7 +25,10 @@ * - `tool_start` — Tool execution begins. * Fields: tool (name), args (first 500 chars of JSON) * - `tool_end` — Tool execution completes. - * Fields: tool (name), duration_ms, is_error + * Fields: tool (name), duration_ms, is_error, result_chars, result_summary?, error_type? + * result_chars: total character count of result content (survives session compaction) + * result_summary: short tool-specific summary (e.g. "10 results", "12.5KB", "finance/get_price_snapshot") + * error_type: error category when tool returned an error (e.g. "fetch_failed", "ssrf_blocked") * * ### Context Management — Preflight (before LLM call) * - `preflight_compact_start` — Preflight compaction triggered. diff --git a/packages/core/src/agent/runner.ts b/packages/core/src/agent/runner.ts index e561213d..da3112a1 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -85,6 +85,51 @@ export function isRotatableError(reason: AuthProfileFailureReason): boolean { return reason === "auth" || reason === "rate_limit" || reason === "billing" || reason === "timeout"; } +// ── Run-log result extraction helpers ────────────────────────────────────── +// Lightweight extractors for tool_end metadata. These mirror the patterns in +// cli/output.ts but are kept separate to avoid CLI-specific dependencies. + +function extractRunLogResultText(result: unknown): string | undefined { + if (!result || typeof result !== "object") return undefined; + const msg = result as { content?: Array<{ type: string; text?: string }> }; + if (Array.isArray(msg.content)) { + for (const c of msg.content) { + if (c.type === "text" && c.text) return c.text; + } + } + return undefined; +} + +function extractRunLogResultDetails(result: unknown): Record | null { + const text = extractRunLogResultText(result); + if (text) { + try { return JSON.parse(text) as Record; } catch { /* non-JSON result */ } + } + const withDetails = result as { details?: unknown }; + if (withDetails?.details && typeof withDetails.details === "object") { + return withDetails.details as Record; + } + return null; +} + +function formatRunLogToolSummary(tool: string, details: Record | null): string | undefined { + if (!details) return undefined; + if (details.error) return `error: ${details.code || details.message || details.error}`; + switch (tool) { + case "web_search": return `${details.count ?? 0} results`; + case "web_fetch": { + const parts: string[] = []; + if (typeof details.length === "number") parts.push(`${(details.length as number / 1024).toFixed(1)}KB`); + if (details.cached) parts.push("cached"); + return parts.join(", ") || undefined; + } + case "data": return `${details.domain}/${details.action}`; + case "glob": return `${details.count ?? 0} files`; + case "exec": return details.exitCode !== undefined ? `exit ${details.exitCode}` : undefined; + default: return undefined; + } +} + export class Agent { private readonly agent: PiAgentCore; private output; @@ -348,9 +393,12 @@ export class Agent { const profileToolsConfig = this.profile?.getToolsConfig(); const mergedToolsConfig = mergeToolsConfig(profileToolsConfig, options.tools); const profileDir = this.profile?.getProfileDir(); + // Use this.sessionId (which may be auto-generated) instead of options.sessionId + // (which may be undefined). Without this, sessions_list and sessions_spawn + // can't find sub-agent runs because they have no session context. this.toolsOptions = mergedToolsConfig - ? { ...options, cwd: effectiveCwd, tools: mergedToolsConfig, profileDir, provider: this.resolvedProvider } - : { ...options, cwd: effectiveCwd, profileDir, provider: this.resolvedProvider }; + ? { ...options, sessionId: this.sessionId, cwd: effectiveCwd, tools: mergedToolsConfig, profileDir, provider: this.resolvedProvider } + : { ...options, sessionId: this.sessionId, cwd: effectiveCwd, profileDir, provider: this.resolvedProvider }; const tools = resolveTools(this.toolsOptions); if (this.debug) { @@ -746,11 +794,24 @@ export class Agent { const startTime = this.toolStartTimes.get(toolName); const duration_ms = startTime ? Date.now() - startTime : undefined; this.toolStartTimes.delete(toolName); - this.runLog.log("tool_end", { + + // Extract result metadata for run-log persistence (survives session compaction) + const result = (event as any).result; + const resultText = extractRunLogResultText(result); + const resultChars = resultText?.length ?? 0; + const details = extractRunLogResultDetails(result); + + const toolEndData: Record = { tool: toolName, duration_ms, is_error: (event as any).isError ?? false, - }); + result_chars: resultChars, + result_summary: formatRunLogToolSummary(toolName, details), + }; + if (details?.error) { + toolEndData.error_type = details.code ? String(details.code) : String(details.error); + } + this.runLog.log("tool_end", toolEndData); } } diff --git a/packages/core/src/agent/skills/index.ts b/packages/core/src/agent/skills/index.ts index e7343eb2..0a84f076 100644 --- a/packages/core/src/agent/skills/index.ts +++ b/packages/core/src/agent/skills/index.ts @@ -5,6 +5,7 @@ * Compatible with OpenClaw/AgentSkills specification */ +import { dirname } from "path"; import type { Skill, SkillManagerOptions, SkillsConfig, SkillCommandSpec, SkillInvocationResult } from "./types.js"; import { loadAllSkills, getProfileSkillsDir, initializeManagedSkills, getManagedSkillsDir } from "./loader.js"; import { @@ -332,6 +333,11 @@ export class SkillManager { parts.push(`## ${emoji} ${name} (${id})`); parts.push(`${desc}\n`); + // Include skill directory path so the agent can resolve relative paths + // (e.g., scripts/recalc.py → /absolute/path/to/skill/scripts/recalc.py) + const skillDir = dirname(skill.filePath); + parts.push(`**Skill directory**: \`${skillDir}\`\n`); + // Include full instructions if (skill.instructions) { parts.push(skill.instructions); diff --git a/packages/core/src/agent/tools/sessions-list.test.ts b/packages/core/src/agent/tools/sessions-list.test.ts index 230d8997..637a8537 100644 --- a/packages/core/src/agent/tools/sessions-list.test.ts +++ b/packages/core/src/agent/tools/sessions-list.test.ts @@ -47,6 +47,8 @@ describe("sessions_list tool", () => { startedAt: now - 60000, endedAt: now - 30000, outcome: { status: "ok" }, + findings: "All tests passed successfully.", + findingsCaptured: true, }), ); seedSubagentRunForTests( @@ -56,6 +58,8 @@ describe("sessions_list tool", () => { startedAt: now - 60000, endedAt: now, outcome: { status: "error", error: "timeout" }, + findings: "Lint check timed out.", + findingsCaptured: true, }), ); @@ -71,6 +75,13 @@ describe("sessions_list tool", () => { expect((text as { text: string }).text).toContain("Code Review"); expect((text as { text: string }).text).toContain("Test Analysis"); expect((text as { text: string }).text).toContain("Lint Check"); + // Verify full runId is shown for completed runs + expect((text as { text: string }).text).toContain("id:run-aaa"); + expect((text as { text: string }).text).toContain("id:run-bbb"); + expect((text as { text: string }).text).toContain("id:run-ccc"); + // Verify findings are shown for completed runs + expect((text as { text: string }).text).toContain("All tests passed successfully."); + expect((text as { text: string }).text).toContain("Lint check timed out."); expect(result.details!.runs).toHaveLength(3); expect(result.details!.runs[0]!.status).toBe("running"); @@ -141,6 +152,44 @@ describe("sessions_list tool", () => { expect(result.details).toEqual({ runs: [] }); }); + it("shows findings for grouped completed runs", async () => { + const now = Date.now(); + const groupId = "group-001"; + seedSubagentRunForTests( + makeRecord({ + runId: "run-g1", + label: "Bull Case Research", + startedAt: now - 60000, + endedAt: now - 10000, + outcome: { status: "ok" }, + findings: "AI infrastructure capex growing 40% YoY.", + findingsCaptured: true, + groupId, + }), + ); + seedSubagentRunForTests( + makeRecord({ + runId: "run-g2", + label: "Bear Case Research", + startedAt: now - 60000, + endedAt: now - 5000, + outcome: { status: "ok" }, + findings: "Valuation risk: forward P/E above historical average.", + findingsCaptured: true, + groupId, + }), + ); + + const tool = createSessionsListTool({ sessionId: "parent-001" }); + const result = await tool.execute("call-1", {}); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("id:run-g1"); + expect(text).toContain("id:run-g2"); + expect(text).toContain("AI infrastructure capex growing 40% YoY."); + expect(text).toContain("Valuation risk: forward P/E above historical average."); + }); + it("shows findings status for running task", async () => { const now = Date.now(); seedSubagentRunForTests( diff --git a/packages/core/src/agent/tools/sessions-list.ts b/packages/core/src/agent/tools/sessions-list.ts index 93ef896c..8905b943 100644 --- a/packages/core/src/agent/tools/sessions-list.ts +++ b/packages/core/src/agent/tools/sessions-list.ts @@ -212,10 +212,13 @@ export function createSessionsListTool( const status = resolveStatus(r); if (status === "running") { const elapsed = r.startedAt ? formatElapsed(now - r.startedAt) : "just spawned"; - statusLines.push(` ${idx}. [RUNNING] "${displayName}" (${elapsed})`); + statusLines.push(` ${idx}. [RUNNING] "${displayName}" (${elapsed}) id:${r.runId}`); } else { const elapsed = r.startedAt && r.endedAt ? formatElapsed(r.endedAt - r.startedAt) : ""; - statusLines.push(` ${idx}. [${status.toUpperCase()}] "${displayName}" (${elapsed})`); + const findings = r.findingsCaptured + ? (r.findings ? r.findings.slice(0, 4000) + (r.findings.length > 4000 ? "…" : "") : "(no output)") + : "(findings not yet captured)"; + statusLines.push(` ${idx}. [${status.toUpperCase()}] "${displayName}" (${elapsed}) id:${r.runId}\n Findings: ${findings}`); } } } @@ -227,13 +230,13 @@ export function createSessionsListTool( const status = resolveStatus(r); if (status === "running") { const elapsed = r.startedAt ? formatElapsed(now - r.startedAt) : "just spawned"; - statusLines.push(` ${idx}. [RUNNING] "${displayName}" (${elapsed})`); + statusLines.push(` ${idx}. [RUNNING] "${displayName}" (${elapsed}) id:${r.runId}`); } else { const elapsed = r.startedAt && r.endedAt ? formatElapsed(r.endedAt - r.startedAt) : ""; const findings = r.findingsCaptured - ? (r.findings ? r.findings.slice(0, 200) + (r.findings.length > 200 ? "…" : "") : "(no output)") + ? (r.findings ? r.findings.slice(0, 4000) + (r.findings.length > 4000 ? "…" : "") : "(no output)") : "(findings not yet captured)"; - statusLines.push(` ${idx}. [${status.toUpperCase()}] "${displayName}" (${elapsed})\n Findings: ${findings}`); + statusLines.push(` ${idx}. [${status.toUpperCase()}] "${displayName}" (${elapsed}) id:${r.runId}\n Findings: ${findings}`); } } diff --git a/packages/core/src/agent/tools/sessions-spawn.test.ts b/packages/core/src/agent/tools/sessions-spawn.test.ts index 9eaff1a7..0a0d80a9 100644 --- a/packages/core/src/agent/tools/sessions-spawn.test.ts +++ b/packages/core/src/agent/tools/sessions-spawn.test.ts @@ -1,7 +1,11 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { createSessionsSpawnTool } from "./sessions-spawn.js"; +import { getSubagentGroup, resetSubagentRegistryForTests } from "../subagent/registry.js"; describe("sessions_spawn tool", () => { + beforeEach(() => { + resetSubagentRegistryForTests(); + }); it("has correct name and description", () => { const tool = createSessionsSpawnTool({ isSubagent: false, sessionId: "test-session" }); expect(tool.name).toBe("sessions_spawn"); @@ -24,6 +28,23 @@ describe("sessions_spawn tool", () => { expect(firstContent.text).toContain("not allowed"); }); + it("auto-creates group when custom groupId is provided", async () => { + const tool = createSessionsSpawnTool({ isSubagent: false, sessionId: "parent-session" }); + + // Should not error — the group is auto-created + await tool.execute( + "call-group", + { task: "research topic", label: "Research", groupId: "my-custom-group" } as any, + new AbortController().signal, + ); + + // Verify group was created in the registry + const group = getSubagentGroup("my-custom-group"); + expect(group).toBeDefined(); + expect(group!.groupId).toBe("my-custom-group"); + expect(group!.label).toBe("Group: Research"); + }); + it("fails gracefully when Hub is not initialized", async () => { const tool = createSessionsSpawnTool({ isSubagent: false, sessionId: "parent-session" }); diff --git a/packages/core/src/agent/tools/sessions-spawn.ts b/packages/core/src/agent/tools/sessions-spawn.ts index 63e615bb..030fbe27 100644 --- a/packages/core/src/agent/tools/sessions-spawn.ts +++ b/packages/core/src/agent/tools/sessions-spawn.ts @@ -126,19 +126,20 @@ export function createSessionsSpawnTool( const runId = uuidv7(); const childSessionId = uuidv7(); - // Validate groupId if provided + // Auto-create group when groupId is provided but doesn't exist yet, + // or when `next` is provided without a groupId. if (groupId) { const existingGroup = getSubagentGroup(groupId); if (!existingGroup) { - return { - content: [{ type: "text", text: `Error: group not found: ${groupId}. Use the groupId returned by a previous sessions_spawn call.` }], - details: { status: "error", error: `group not found: ${groupId}` }, - }; + // LLM provided a custom groupId — auto-create the group + createSubagentGroup({ + groupId, + requesterSessionId, + label: label ? `Group: ${label}` : undefined, + next, + }); } - } - - // Auto-create group when `next` is provided without an existing groupId - if (!groupId && next) { + } else if (next) { groupId = uuidv7(); createSubagentGroup({ groupId, diff --git a/packages/core/src/agent/tools/web/web-fetch.ts b/packages/core/src/agent/tools/web/web-fetch.ts index 32a1e079..476e9400 100644 --- a/packages/core/src/agent/tools/web/web-fetch.ts +++ b/packages/core/src/agent/tools/web/web-fetch.ts @@ -343,12 +343,14 @@ export function createWebFetchTool(): AgentTool } catch (error) { if (error instanceof SsrfBlockedError) { return jsonResult({ - error: "ssrf_blocked", + error: true, + code: "ssrf_blocked", message: error.message, }); } return jsonResult({ - error: "fetch_failed", + error: true, + code: "fetch_failed", message: error instanceof Error ? error.message : String(error), }); } diff --git a/packages/core/src/agent/tools/web/web-search.ts b/packages/core/src/agent/tools/web/web-search.ts index 5d2bece1..5dff6ed8 100644 --- a/packages/core/src/agent/tools/web/web-search.ts +++ b/packages/core/src/agent/tools/web/web-search.ts @@ -135,7 +135,8 @@ export function createWebSearchTool(): AgentTool [timeout_seconds] +python3 scripts/recalc.py [timeout_seconds] ``` Example: ```bash -python scripts/recalc.py output.xlsx 30 +python3 scripts/recalc.py output.xlsx 30 ``` The script: