From 0af7d2e8d12862296cee945f240dabbde72ad876 Mon Sep 17 00:00:00 2001 From: Steve James Date: Thu, 15 Jan 2026 09:23:37 +0100 Subject: [PATCH 1/8] improve error handling in summary --- lib/runtime/plugins/context.ts | 40 ++++++++++++++++++++++++---------- package-lock.json | 2 +- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/lib/runtime/plugins/context.ts b/lib/runtime/plugins/context.ts index f870e12..49d745e 100644 --- a/lib/runtime/plugins/context.ts +++ b/lib/runtime/plugins/context.ts @@ -276,20 +276,38 @@ export const context: AgentPlugin = { // Build summarization prompt const summaryPrompt = buildSummaryPrompt(checkpoint?.summary, toSummarize); - // Call LLM for summarization + // Emit event so we know summarization started + ctx.agent.emit("context.summarizing", { + messageCount: toSummarize.length, + keepingRecent: toKeep.length, + }); + + // Call LLM for summarization with timeout + const SUMMARIZATION_TIMEOUT_MS = 60000; // 60s max let summaryResult; try { - summaryResult = await ctx.agent.provider.invoke( - { - model: SUMMARY_MODEL ?? ctx.agent.model, - systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, - messages: [{ role: "user", content: summaryPrompt }], - toolDefs: [], - }, - {} - ); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SUMMARIZATION_TIMEOUT_MS); + try { + summaryResult = await ctx.agent.provider.invoke( + { + model: SUMMARY_MODEL ?? ctx.agent.model, + systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, + messages: [{ role: "user", content: summaryPrompt }], + toolDefs: [], + }, + { signal: controller.signal } + ); + } finally { + clearTimeout(timeout); + } } catch (err) { - console.error("context: Summarization failed:", err); + const errorMsg = err instanceof Error ? err.message : String(err); + ctx.agent.emit("context.error", { + phase: "summarization", + error: errorMsg, + }); + console.error("context: Summarization failed:", errorMsg); return; } diff --git a/package-lock.json b/package-lock.json index df13bb9..c52fdf3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ }, "lib": { "name": "agents-hub", - "version": "0.0.11", + "version": "0.0.12", "license": "MIT", "dependencies": { "@cloudflare/vite-plugin": "^1.0.0", From fbd825e8f64cc225c2f86b7fb23bc3edceb5ca6d Mon Sep 17 00:00:00 2001 From: Steve James Date: Thu, 15 Jan 2026 10:35:36 +0100 Subject: [PATCH 2/8] loop and plugin tests --- lib/runtime/agent/index.ts | 3 + lib/runtime/hub.ts | 4 +- lib/runtime/persisted.ts | 13 + lib/tests/agent-loop.test.ts | 438 +++++++++++++++++++ lib/tests/plugins-integration.test.ts | 582 ++++++++++++++++++++++++++ lib/tests/worker.ts | 40 +- 6 files changed, 1070 insertions(+), 10 deletions(-) create mode 100644 lib/tests/agent-loop.test.ts create mode 100644 lib/tests/plugins-integration.test.ts diff --git a/lib/runtime/agent/index.ts b/lib/runtime/agent/index.ts index 5c14c64..29e5a8e 100644 --- a/lib/runtime/agent/index.ts +++ b/lib/runtime/agent/index.ts @@ -337,6 +337,9 @@ export abstract class HubAgent< this.info.pendingToolCalls = toolCalls; } + // HITL plugin may have paused the agent in onModelResult + if (this.isPaused) return; + await this.executePendingTools(MAX_TOOLS_PER_TICK); if (this.isPaused) return; diff --git a/lib/runtime/hub.ts b/lib/runtime/hub.ts index 3801aa4..12849e9 100644 --- a/lib/runtime/hub.ts +++ b/lib/runtime/hub.ts @@ -242,8 +242,8 @@ class ToolRegistry { for (const cap of capabilities) { if (cap.startsWith("@")) { - const tag = cap.slice(1); - const toolNames = this.tags.get(tag) || []; + // Tags are stored WITH the @ prefix, so look up directly + const toolNames = this.tags.get(cap) || []; for (const name of toolNames) { if (!seen.has(name)) { seen.add(name); diff --git a/lib/runtime/persisted.ts b/lib/runtime/persisted.ts index dcc0c1f..397dda5 100644 --- a/lib/runtime/persisted.ts +++ b/lib/runtime/persisted.ts @@ -154,6 +154,19 @@ export function PersistedObject>( return true; }, + has(_t, prop) { + if (typeof prop !== "string") return prop in target; + if (prop in target) return true; + const k = keyOf(prop); + // Check cache first + if (cache.has(k)) return true; + // Check KV storage + if (kv.get(k) !== undefined) return true; + // Check defaults + if (prop in defaults) return true; + return false; + }, + ownKeys() { if (typeof kv.list === "function") { const listed = kv.list({ prefix }); diff --git a/lib/tests/agent-loop.test.ts b/lib/tests/agent-loop.test.ts new file mode 100644 index 0000000..e1b38e4 --- /dev/null +++ b/lib/tests/agent-loop.test.ts @@ -0,0 +1,438 @@ +import { env } from "cloudflare:test"; +import { describe, expect, it, beforeEach } from "vitest"; +import { getAgentByName } from "agents"; +import { testProvider } from "./worker"; +import type { Env } from "./worker"; + +declare module "cloudflare:test" { + interface ProvidedEnv extends Env {} +} + +/** + * Helper to wait for agent to reach a specific status. + * Polls the agent state until the status matches or timeout. + */ +async function waitForStatus( + agentStub: { fetch: (req: Request) => Promise }, + expectedStatus: string, + timeoutMs = 5000, + pollMs = 50 +): Promise<{ state: Record; run: { status: string } }> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const res = await agentStub.fetch(new Request("http://do/state")); + const data = await res.json() as { state: Record; run: { status: string } }; + if (data.run.status === expectedStatus) { + return data; + } + // If status is an error state, return immediately + if (["error", "canceled"].includes(data.run.status)) { + return data; + } + await new Promise((r) => setTimeout(r, pollMs)); + } + throw new Error(`Timeout waiting for status "${expectedStatus}"`); +} + +/** + * Helper to spawn an agent and return the agent stub. + */ +async function spawnAgent(agencyName: string): Promise<{ + agentId: string; + agentStub: { fetch: (req: Request) => Promise }; + agencyStub: { fetch: (req: Request | string) => Promise }; +}> { + const agencyStub = await getAgentByName(env.AGENCY, agencyName); + + const spawnRes = await agencyStub.fetch( + new Request("http://do/agents", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentType: "test-agent" }), + }) + ); + + expect(spawnRes.ok).toBe(true); + const { id: agentId } = await spawnRes.json() as { id: string }; + const agentStub = await getAgentByName(env.HUB_AGENT, agentId); + + return { agentId, agentStub, agencyStub }; +} + +describe("Agent Loop Integration", () => { + beforeEach(() => { + // Reset the test provider before each test + testProvider.reset(); + }); + + describe("Simple completion (no tools)", () => { + it("should complete when model returns text without tool calls", async () => { + // Arrange: Queue a simple text response + testProvider.addResponse("Hello! I'm here to help."); + + // Act: Spawn and invoke the agent + const { agentStub } = await spawnAgent("loop-simple-completion"); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Hello" }], + }), + }) + ); + + // Wait for completion + const result = await waitForStatus(agentStub, "completed"); + + // Assert + expect(result.run.status).toBe("completed"); + expect(testProvider.requests).toHaveLength(1); + // Messages have extra fields (ts, toolCalls, etc.) - check role and content + const userMsg = testProvider.requests[0].messages.find( + (m) => m.role === "user" && "content" in m && m.content === "Hello" + ); + expect(userMsg).toBeDefined(); + + // Verify the assistant message is in state + const messages = result.state.messages as Array<{ role: string; content?: string }>; + const assistantMsg = messages.find((m) => m.role === "assistant"); + expect(assistantMsg?.content).toBe("Hello! I'm here to help."); + }); + }); + + describe("Single tool call", () => { + it("should execute tool and complete with result", async () => { + // Arrange: Model calls echo tool, then completes + testProvider.addResponse({ + toolCalls: [{ id: "call_1", name: "echo", args: { message: "test message" } }], + }); + testProvider.addResponse("The echo result was: Echo: test message"); + + // Act + const { agentStub } = await spawnAgent("loop-single-tool"); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Please echo 'test message'" }], + }), + }) + ); + + const result = await waitForStatus(agentStub, "completed"); + + // Assert + expect(result.run.status).toBe("completed"); + expect(testProvider.requests).toHaveLength(2); // Initial + after tool result + expect(testProvider.toolCalls).toHaveLength(1); + expect(testProvider.toolCalls[0].name).toBe("echo"); + + // Verify tool result message was added + const messages = result.state.messages as Array<{ role: string; content?: string; toolCallId?: string }>; + const toolResultMsg = messages.find((m) => m.role === "tool"); + expect(toolResultMsg?.content).toBe("Echo: test message"); + expect(toolResultMsg?.toolCallId).toBe("call_1"); + }); + + it("should handle tool returning structured data", async () => { + // Arrange: Model calls add tool which returns { result: number } + testProvider.addResponse({ + toolCalls: [{ id: "call_1", name: "add", args: { a: 5, b: 3 } }], + }); + testProvider.addResponse("5 + 3 = 8"); + + // Act + const { agentStub } = await spawnAgent("loop-structured-tool"); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "What is 5 + 3?" }], + }), + }) + ); + + const result = await waitForStatus(agentStub, "completed"); + + // Assert + expect(result.run.status).toBe("completed"); + expect(testProvider.toolCalls[0]).toEqual({ + id: "call_1", + name: "add", + args: { a: 5, b: 3 }, + }); + + // Structured result should be JSON-stringified + const messages = result.state.messages as Array<{ role: string; content?: string }>; + const toolResultMsg = messages.find((m) => m.role === "tool"); + expect(toolResultMsg?.content).toBe('{"result":8}'); + }); + }); + + describe("Multi-turn tool calls", () => { + it("should handle multiple sequential tool calls", async () => { + // Arrange: Model makes two tool calls in sequence + testProvider.addResponse({ + toolCalls: [{ id: "call_1", name: "add", args: { a: 2, b: 3 } }], + }); + testProvider.addResponse({ + toolCalls: [{ id: "call_2", name: "add", args: { a: 5, b: 4 } }], + }); + testProvider.addResponse("First result was 5, second result was 9."); + + // Act + const { agentStub } = await spawnAgent("loop-multi-turn"); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Add 2+3, then add 5+4" }], + }), + }) + ); + + const result = await waitForStatus(agentStub, "completed"); + + // Assert + expect(result.run.status).toBe("completed"); + expect(testProvider.requests).toHaveLength(3); + expect(testProvider.toolCalls).toHaveLength(2); + + // Verify the conversation history shows both tool interactions + const messages = result.state.messages as Array<{ role: string }>; + const toolMessages = messages.filter((m) => m.role === "tool"); + expect(toolMessages).toHaveLength(2); + }); + }); + + describe("Parallel tool calls", () => { + it("should execute multiple tools in parallel when model requests them together", async () => { + // Arrange: Model requests two tool calls at once + testProvider.addResponse({ + toolCalls: [ + { id: "call_1", name: "add", args: { a: 1, b: 2 } }, + { id: "call_2", name: "add", args: { a: 3, b: 4 } }, + ], + }); + testProvider.addResponse("Results: 3 and 7"); + + // Act + const { agentStub } = await spawnAgent("loop-parallel-tools"); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Calculate 1+2 and 3+4 at the same time" }], + }), + }) + ); + + const result = await waitForStatus(agentStub, "completed"); + + // Assert + expect(result.run.status).toBe("completed"); + expect(testProvider.toolCalls).toHaveLength(2); + + // Both tool results should be in messages + const messages = result.state.messages as Array<{ role: string; toolCallId?: string }>; + const toolMessages = messages.filter((m) => m.role === "tool"); + expect(toolMessages).toHaveLength(2); + expect(toolMessages.map((m) => m.toolCallId).sort()).toEqual(["call_1", "call_2"]); + }); + }); + + describe("Error handling", () => { + it("should handle tool not found gracefully", async () => { + // Arrange: Model tries to call a tool that doesn't exist + testProvider.addResponse({ + toolCalls: [{ id: "call_1", name: "nonexistent_tool", args: {} }], + }); + testProvider.addResponse("I couldn't find that tool, but I'll try something else."); + + // Act + const { agentStub } = await spawnAgent("loop-tool-not-found"); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Use a fake tool" }], + }), + }) + ); + + const result = await waitForStatus(agentStub, "completed"); + + // Assert: Should complete (error is passed back to model which can recover) + expect(result.run.status).toBe("completed"); + + // The tool result should contain the error + const messages = result.state.messages as Array<{ role: string; content?: string }>; + const toolResultMsg = messages.find((m) => m.role === "tool"); + expect(toolResultMsg?.content).toContain("Error:"); + expect(toolResultMsg?.content).toContain("not found"); + }); + + it("should reach error status when max iterations exceeded", async () => { + // Arrange: Model keeps calling tools forever + testProvider.onRequest(() => ({ + toolCalls: [{ id: `call_${Date.now()}`, name: "echo", args: { message: "loop" } }], + })); + + // Act + const { agentStub } = await spawnAgent("loop-max-iterations"); + + // Set a very low iteration limit + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Keep going" }], + vars: { MAX_ITERATIONS: 3 }, + }), + }) + ); + + const result = await waitForStatus(agentStub, "error", 10000); + + // Assert + expect(result.run.status).toBe("error"); + }); + }); + + describe("Cancellation", () => { + it("should stop the agent loop when canceled", async () => { + // Arrange: Model would keep running but we cancel it + testProvider.onRequest(() => ({ + toolCalls: [{ id: `call_${Date.now()}`, name: "echo", args: { message: "working" } }], + })); + + // Act + const { agentStub } = await spawnAgent("loop-cancel"); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Do something" }], + }), + }) + ); + + // Give it a moment to start, then cancel + await new Promise((r) => setTimeout(r, 100)); + + const cancelRes = await agentStub.fetch( + new Request("http://do/action", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "cancel" }), + }) + ); + + expect(cancelRes.ok).toBe(true); + + const result = await waitForStatus(agentStub, "canceled"); + expect(result.run.status).toBe("canceled"); + }); + }); + + describe("Events", () => { + it("should emit events throughout the agent loop", async () => { + // Arrange + testProvider.addResponse({ + toolCalls: [{ id: "call_1", name: "echo", args: { message: "hi" } }], + }); + testProvider.addResponse("Done!"); + + // Act + const { agentStub } = await spawnAgent("loop-events"); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Echo hi" }], + }), + }) + ); + + await waitForStatus(agentStub, "completed"); + + // Get events + const eventsRes = await agentStub.fetch(new Request("http://do/events")); + const { events } = await eventsRes.json() as { events: Array<{ type: string; data: unknown }> }; + + // Assert: Should have key lifecycle events + const eventTypes = events.map((e) => e.type); + + // Event types are lowercase like "run.started" + expect(eventTypes).toContain("run.started"); + expect(eventTypes).toContain("run.tick"); + expect(eventTypes).toContain("model.started"); + expect(eventTypes).toContain("model.completed"); + expect(eventTypes).toContain("tool.started"); + expect(eventTypes).toContain("tool.output"); + expect(eventTypes).toContain("assistant.message"); + expect(eventTypes).toContain("agent.completed"); + }); + }); + + describe("Context preservation", () => { + it("should maintain conversation history across turns", async () => { + // Arrange + testProvider.addResponse("I'll remember that!"); + testProvider.addResponse("You told me your name is Alice."); + + // Act - first turn + const { agentStub } = await spawnAgent("loop-context"); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "My name is Alice" }], + }), + }) + ); + + await waitForStatus(agentStub, "completed"); + + // Act - second turn (new invoke) + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "What is my name?" }], + }), + }) + ); + + await waitForStatus(agentStub, "completed"); + + // Assert: Second request should include full history + expect(testProvider.requests).toHaveLength(2); + const secondRequest = testProvider.requests[1]; + const userMessages = secondRequest.messages.filter((m) => m.role === "user"); + + // Should have both user messages from the conversation + expect(userMessages).toHaveLength(2); + }); + }); +}); diff --git a/lib/tests/plugins-integration.test.ts b/lib/tests/plugins-integration.test.ts new file mode 100644 index 0000000..0f52f1c --- /dev/null +++ b/lib/tests/plugins-integration.test.ts @@ -0,0 +1,582 @@ +import { env } from "cloudflare:test"; +import { describe, expect, it, beforeEach } from "vitest"; +import { getAgentByName } from "agents"; +import { testProvider } from "./worker"; +import type { Env } from "./worker"; + +declare module "cloudflare:test" { + interface ProvidedEnv extends Env {} +} + +/** + * Helper to wait for agent to reach a specific status. + */ +async function waitForStatus( + agentStub: { fetch: (req: Request) => Promise }, + expectedStatus: string, + timeoutMs = 5000, + pollMs = 50 +): Promise<{ state: Record; run: { status: string; reason?: string } }> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const res = await agentStub.fetch(new Request("http://do/state")); + const data = (await res.json()) as { + state: Record; + run: { status: string; reason?: string }; + }; + if (data.run.status === expectedStatus) { + return data; + } + // If status is a terminal state, return immediately + if (["error", "canceled", "completed"].includes(data.run.status)) { + return data; + } + await new Promise((r) => setTimeout(r, pollMs)); + } + throw new Error(`Timeout waiting for status "${expectedStatus}"`); +} + +/** + * Helper to spawn an agent and return stubs. + */ +async function spawnAgent(agencyName: string): Promise<{ + agentId: string; + agentStub: { fetch: (req: Request) => Promise }; + agencyStub: { fetch: (req: Request | string) => Promise }; +}> { + const agencyStub = await getAgentByName(env.AGENCY, agencyName); + + const spawnRes = await agencyStub.fetch( + new Request("http://do/agents", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentType: "test-agent" }), + }) + ); + + expect(spawnRes.ok).toBe(true); + const { id: agentId } = (await spawnRes.json()) as { id: string }; + const agentStub = await getAgentByName(env.HUB_AGENT, agentId); + + return { agentId, agentStub, agencyStub }; +} + +describe("Plugin Integration Tests", () => { + beforeEach(() => { + testProvider.reset(); + }); + + describe("vars plugin", () => { + it("should resolve $VAR_NAME in tool arguments", async () => { + // Arrange: Set up agency vars BEFORE spawning agent + testProvider.addResponse({ + toolCalls: [{ id: "call_1", name: "echo", args: { message: "$MY_MESSAGE" } }], + }); + testProvider.addResponse("Done echoing!"); + + // Get agency stub first and set vars BEFORE spawning agent + const agencyStub = await getAgentByName(env.AGENCY, "vars-integration-test"); + await agencyStub.fetch( + new Request("http://do/vars", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ MY_MESSAGE: "Hello from vars!" }), + }) + ); + + // Now spawn the agent (it will inherit the vars) + const spawnRes = await agencyStub.fetch( + new Request("http://do/agents", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentType: "test-agent" }), + }) + ); + expect(spawnRes.ok).toBe(true); + const { id: agentId } = (await spawnRes.json()) as { id: string }; + const agentStub = await getAgentByName(env.HUB_AGENT, agentId); + + // Invoke the agent - pass vars in the invoke body to ensure they're there + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Echo my message using $MY_MESSAGE" }], + vars: { MY_MESSAGE: "Hello from vars!" }, + }), + }) + ); + + const result = await waitForStatus(agentStub, "completed"); + + // Assert: The tool result should show the resolved var value + expect(result.run.status).toBe("completed"); + const messages = result.state.messages as Array<{ role: string; content?: string }>; + const toolResultMsg = messages.find((m) => m.role === "tool"); + expect(toolResultMsg?.content).toBe("Echo: Hello from vars!"); + }); + + it("should preserve non-string types when resolving full var reference", async () => { + // Arrange + testProvider.addResponse({ + toolCalls: [{ id: "call_1", name: "add", args: { a: "$NUM_A", b: "$NUM_B" } }], + }); + testProvider.addResponse("The sum is 15"); + + const { agentStub } = await spawnAgent("vars-type-integration"); + + // Pass vars directly in invoke body + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Add $NUM_A and $NUM_B" }], + vars: { NUM_A: 10, NUM_B: 5 }, + }), + }) + ); + + const result = await waitForStatus(agentStub, "completed"); + + // Assert: Tool should receive numeric values and compute correctly + expect(result.run.status).toBe("completed"); + const messages = result.state.messages as Array<{ role: string; content?: string }>; + const toolResultMsg = messages.find((m) => m.role === "tool"); + expect(toolResultMsg?.content).toBe('{"result":15}'); + }); + + it("should support string interpolation with multiple vars", async () => { + // Arrange + testProvider.addResponse({ + toolCalls: [ + { id: "call_1", name: "echo", args: { message: "Hello $NAME, your score is $SCORE!" } }, + ], + }); + testProvider.addResponse("Done!"); + + const { agentStub } = await spawnAgent("vars-interpolation"); + + // Pass vars directly in invoke body + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Greet me" }], + vars: { NAME: "Alice", SCORE: 100 }, + }), + }) + ); + + const result = await waitForStatus(agentStub, "completed"); + + // Assert + expect(result.run.status).toBe("completed"); + const messages = result.state.messages as Array<{ role: string; content?: string }>; + const toolResultMsg = messages.find((m) => m.role === "tool"); + expect(toolResultMsg?.content).toBe("Echo: Hello Alice, your score is 100!"); + }); + }); + + describe("hitl plugin", () => { + it("should pause agent when risky tool is called", async () => { + // Arrange: Set HITL_TOOLS BEFORE spawning + testProvider.addResponse({ + toolCalls: [{ id: "call_1", name: "echo", args: { message: "risky operation" } }], + }); + + const agencyStub = await getAgentByName(env.AGENCY, "hitl-pause-test"); + await agencyStub.fetch( + new Request("http://do/vars", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ HITL_TOOLS: ["echo"] }), + }) + ); + + const spawnRes = await agencyStub.fetch( + new Request("http://do/agents", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentType: "test-agent" }), + }) + ); + expect(spawnRes.ok).toBe(true); + const { id: agentId } = (await spawnRes.json()) as { id: string }; + const agentStub = await getAgentByName(env.HUB_AGENT, agentId); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Do the risky thing" }], + }), + }) + ); + + // Wait for paused status + const result = await waitForStatus(agentStub, "paused"); + + // Assert + expect(result.run.status).toBe("paused"); + expect(result.run.reason).toBe("hitl"); + }); + + it("should resume and complete after approval", async () => { + // Arrange: Set vars BEFORE spawning + testProvider.addResponse({ + toolCalls: [{ id: "call_1", name: "echo", args: { message: "approved" } }], + }); + testProvider.addResponse("Operation completed successfully!"); + + const agencyStub = await getAgentByName(env.AGENCY, "hitl-approve-test-2"); + await agencyStub.fetch( + new Request("http://do/vars", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ HITL_TOOLS: ["echo"] }), + }) + ); + + const spawnRes = await agencyStub.fetch( + new Request("http://do/agents", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentType: "test-agent" }), + }) + ); + expect(spawnRes.ok).toBe(true); + const { id: agentId } = (await spawnRes.json()) as { id: string }; + const agentStub = await getAgentByName(env.HUB_AGENT, agentId); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Do the risky thing" }], + }), + }) + ); + + // Wait for paused + await waitForStatus(agentStub, "paused"); + + // Approve + const approveRes = await agentStub.fetch( + new Request("http://do/action", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "approve", approved: true }), + }) + ); + expect(approveRes.ok).toBe(true); + + // Wait for completion + const result = await waitForStatus(agentStub, "completed"); + + // Assert + expect(result.run.status).toBe("completed"); + const messages = result.state.messages as Array<{ role: string; content?: string }>; + const toolResultMsg = messages.find((m) => m.role === "tool"); + expect(toolResultMsg?.content).toBe("Echo: approved"); + }); + + it("should allow modifying tool calls on approval", async () => { + // Arrange: Set vars BEFORE spawning + testProvider.addResponse({ + toolCalls: [{ id: "call_1", name: "echo", args: { message: "original" } }], + }); + testProvider.addResponse("Modified operation completed!"); + + const agencyStub = await getAgentByName(env.AGENCY, "hitl-modify-test"); + await agencyStub.fetch( + new Request("http://do/vars", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ HITL_TOOLS: ["echo"] }), + }) + ); + + const spawnRes = await agencyStub.fetch( + new Request("http://do/agents", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentType: "test-agent" }), + }) + ); + expect(spawnRes.ok).toBe(true); + const { id: agentId } = (await spawnRes.json()) as { id: string }; + const agentStub = await getAgentByName(env.HUB_AGENT, agentId); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Do something" }], + }), + }) + ); + + await waitForStatus(agentStub, "paused"); + + // Approve with modified tool calls + await agentStub.fetch( + new Request("http://do/action", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + type: "approve", + approved: true, + modifiedToolCalls: [{ id: "call_1", name: "echo", args: { message: "modified by human" } }], + }), + }) + ); + + const result = await waitForStatus(agentStub, "completed"); + + // Assert: Should use the modified args + expect(result.run.status).toBe("completed"); + const messages = result.state.messages as Array<{ role: string; content?: string }>; + const toolResultMsg = messages.find((m) => m.role === "tool"); + expect(toolResultMsg?.content).toBe("Echo: modified by human"); + }); + + it("should not pause for non-watched tools", async () => { + // Arrange: Set vars BEFORE spawning - only watch 'add' + testProvider.addResponse({ + toolCalls: [{ id: "call_1", name: "echo", args: { message: "safe" } }], + }); + testProvider.addResponse("Done!"); + + const agencyStub = await getAgentByName(env.AGENCY, "hitl-safe-tool-test"); + await agencyStub.fetch( + new Request("http://do/vars", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ HITL_TOOLS: ["add"] }), + }) + ); + + const spawnRes = await agencyStub.fetch( + new Request("http://do/agents", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentType: "test-agent" }), + }) + ); + expect(spawnRes.ok).toBe(true); + const { id: agentId } = (await spawnRes.json()) as { id: string }; + const agentStub = await getAgentByName(env.HUB_AGENT, agentId); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Echo something" }], + }), + }) + ); + + // Should complete without pausing + const result = await waitForStatus(agentStub, "completed"); + expect(result.run.status).toBe("completed"); + }); + }); + + describe("planning plugin", () => { + it("should allow model to create todos via write_todos tool", async () => { + // Arrange: Model calls write_todos + testProvider.addResponse({ + toolCalls: [ + { + id: "call_1", + name: "write_todos", + args: { + todos: [ + { content: "First task", status: "pending" }, + { content: "Second task", status: "in_progress" }, + ], + }, + }, + ], + }); + testProvider.addResponse("I've created a todo list for you!"); + + // Act + const { agentStub } = await spawnAgent("planning-write-test"); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Create a task list for implementing a feature" }], + }), + }) + ); + + const result = await waitForStatus(agentStub, "completed"); + + // Assert + expect(result.run.status).toBe("completed"); + const todos = result.state.todos as Array<{ content: string; status: string }>; + expect(todos).toHaveLength(2); + expect(todos[0]).toEqual({ content: "First task", status: "pending" }); + expect(todos[1]).toEqual({ content: "Second task", status: "in_progress" }); + }); + + it("should persist todos across multiple invocations", async () => { + // Arrange: Two invocations - first creates todos, second updates them + testProvider.addResponse({ + toolCalls: [ + { + id: "call_1", + name: "write_todos", + args: { + todos: [ + { content: "Task A", status: "pending" }, + { content: "Task B", status: "pending" }, + ], + }, + }, + ], + }); + testProvider.addResponse("Created 2 tasks"); + testProvider.addResponse({ + toolCalls: [ + { + id: "call_2", + name: "write_todos", + args: { + todos: [ + { content: "Task A", status: "completed" }, + { content: "Task B", status: "in_progress" }, + { content: "Task C", status: "pending" }, + ], + }, + }, + ], + }); + testProvider.addResponse("Updated the task list"); + + // Act - First invocation + const { agentStub } = await spawnAgent("planning-persist-test"); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Create tasks" }], + }), + }) + ); + + await waitForStatus(agentStub, "completed"); + + // Act - Second invocation + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Mark Task A complete and add Task C" }], + }), + }) + ); + + const result = await waitForStatus(agentStub, "completed"); + + // Assert: Should have updated todos + const todos = result.state.todos as Array<{ content: string; status: string }>; + expect(todos).toHaveLength(3); + expect(todos[0]).toEqual({ content: "Task A", status: "completed" }); + expect(todos[1]).toEqual({ content: "Task B", status: "in_progress" }); + expect(todos[2]).toEqual({ content: "Task C", status: "pending" }); + }); + + it("should have write_todos tool available during model call", async () => { + // The write_todos tool is registered dynamically in beforeModel, so we verify + // by checking the model request includes the tool definition + testProvider.addResponse("I see the task list tool is available."); + + const { agentStub } = await spawnAgent("planning-tools-test"); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "What tools do you have?" }], + }), + }) + ); + + await waitForStatus(agentStub, "completed"); + + // Check the model request included write_todos + expect(testProvider.requests).toHaveLength(1); + const toolDefs = testProvider.requests[0].toolDefs ?? []; + const hasWriteTodos = toolDefs.some((t: { name: string }) => t.name === "write_todos"); + expect(hasWriteTodos).toBe(true); + }); + }); + + describe("context plugin", () => { + it("should track checkpoint state", async () => { + // Simple test - just verify context plugin state is exposed + testProvider.addResponse("Hello!"); + + const { agentStub } = await spawnAgent("context-state-test"); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Hi" }], + }), + }) + ); + + await waitForStatus(agentStub, "completed"); + + const stateRes = await agentStub.fetch(new Request("http://do/state")); + const state = (await stateRes.json()) as { + state: { hasCheckpoint: boolean; checkpointCount: number }; + }; + + // Context plugin exposes checkpoint state + expect(state.state.hasCheckpoint).toBe(false); + expect(state.state.checkpointCount).toBe(0); + }); + }); + + describe("logger plugin", () => { + it("should log events without affecting agent behavior", async () => { + // Logger is passive - just verify agent still works with it enabled + testProvider.addResponse("Logged response"); + + const { agentStub } = await spawnAgent("logger-integration-test"); + + await agentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Test logging" }], + }), + }) + ); + + const result = await waitForStatus(agentStub, "completed"); + expect(result.run.status).toBe("completed"); + }); + }); +}); diff --git a/lib/tests/worker.ts b/lib/tests/worker.ts index bb545de..1748b8e 100644 --- a/lib/tests/worker.ts +++ b/lib/tests/worker.ts @@ -1,7 +1,28 @@ import { AgentHub, type AgentBlueprint, plugins } from "../runtime"; import { tool, z } from "../runtime/tools"; -import type { HubAgent as HubAgentType } from "../runtime/agent"; -import type { Agency as AgencyType } from "../runtime/agency"; +import { TestProvider } from "../runtime/providers/test"; + +/** + * Shared TestProvider instance for all tests. + * Tests can queue responses and assert on requests. + * + * @example + * ```ts + * import { testProvider } from "./worker"; + * + * beforeEach(() => testProvider.reset()); + * + * it("should call the echo tool", async () => { + * testProvider.addResponse({ + * toolCalls: [{ id: "1", name: "echo", args: { message: "hello" } }] + * }); + * testProvider.addResponse("Done!"); + * // ... run the agent + * expect(testProvider.toolCalls).toHaveLength(1); + * }); + * ``` + */ +export const testProvider = new TestProvider(); const echoTool = tool({ name: "echo", @@ -31,28 +52,31 @@ const testBlueprint: AgentBlueprint = { name: "test-agent", description: "A test agent for unit tests", prompt: "You are a test agent. Use the tools provided to help with testing.", - capabilities: ["@test", "@default", "@hitl"], + capabilities: ["@test", "@default", "@hitl", "@context"], model: "gpt-4o-mini", }; -const hub = new AgentHub({ defaultModel: "gpt-4o-mini" }) +const hub = new AgentHub({ defaultModel: "gpt-4o-mini", provider: testProvider }) .addTool(echoTool, ["@test"]) .addTool(addTool, ["@test"]) .use(plugins.vars) .use(plugins.planning) .use(plugins.hitl) + .use(plugins.context) .use(plugins.logger, ["@test"]) .addAgent(testBlueprint); // Export with standard names so ctx.exports.Agency and ctx.exports.HubAgent work export const { HubAgent, Agency, handler } = hub.export(); +// Re-export TestProvider type for test files +export type { TestProvider } from "../runtime/providers/test"; + // Types are available at runtime via @cloudflare/vitest-pool-workers -// eslint-disable-next-line @typescript-eslint/no-explicit-any export interface Env { - HUB_AGENT: any; - AGENCY: any; - FS: any; + HUB_AGENT: DurableObjectNamespace; + AGENCY: DurableObjectNamespace; + FS: R2Bucket; } // Export the handler directly - it has a fetch method that vitest-pool-workers expects From 2bf26509a6f024cb9b01181ebbbfa7ff97b33dbf Mon Sep 17 00:00:00 2001 From: Steve James Date: Thu, 15 Jan 2026 10:55:32 +0100 Subject: [PATCH 3/8] add ui improvements, more plugin tests and move subagents to lib --- examples/personal-hub/ui/App.tsx | 176 ++++--- .../personal-hub/ui/components/ChatView.tsx | 39 +- .../ui/components/ErrorBoundary.tsx | 100 ++++ .../ui/components/SettingsView.tsx | 7 +- examples/personal-hub/ui/components/Toast.tsx | 135 +++++ .../personal-hub/ui/components/TodosView.tsx | 15 +- examples/personal-hub/ui/components/index.ts | 6 + .../ui/components/shared/constants.ts | 91 +--- .../ui/components/shared/types.ts | 9 +- .../personal-hub/ui/hooks/useAgentSystem.ts | 490 +++++++++++++----- lib/runtime/plugins/index.ts | 2 + lib/runtime/plugins/subagent-reporter.ts | 49 ++ lib/runtime/plugins/subagents.ts | 472 +++++++++++++++++ lib/tests/subagents.test.ts | 217 ++++++++ lib/tests/worker.ts | 24 +- 15 files changed, 1476 insertions(+), 356 deletions(-) create mode 100644 examples/personal-hub/ui/components/ErrorBoundary.tsx create mode 100644 examples/personal-hub/ui/components/Toast.tsx create mode 100644 lib/runtime/plugins/subagent-reporter.ts create mode 100644 lib/runtime/plugins/subagents.ts create mode 100644 lib/tests/subagents.test.ts diff --git a/examples/personal-hub/ui/App.tsx b/examples/personal-hub/ui/App.tsx index 802482c..1bc6c0f 100644 --- a/examples/personal-hub/ui/App.tsx +++ b/examples/personal-hub/ui/App.tsx @@ -11,6 +11,9 @@ import { ConfirmModal, MindPanel, HomeView, + ErrorBoundary, + ToastProvider, + useToast, type TabId, type Message, type Todo, @@ -48,11 +51,6 @@ const queryClient = new QueryClient({ // Helper Functions // ============================================================================ -// System blueprints start with _ and are hidden from the picker -function isSystemBlueprintLocal(bp: AgentBlueprint): boolean { - return bp.name.startsWith("_"); -} - // Blueprint picker component function BlueprintPicker({ blueprints, @@ -64,7 +62,7 @@ function BlueprintPicker({ onClose: () => void; }) { // Filter out system blueprints - const visibleBlueprints = blueprints.filter((bp) => !isSystemBlueprintLocal(bp)); + const visibleBlueprints = blueprints.filter((bp) => !isSystemBlueprint(bp)); return (
@@ -524,6 +522,7 @@ function AgentView({ loading: agentLoading, } = useAgent(agencyId, agentId); const [, navigate] = useLocation(); + const { showError } = useToast(); // Event detail modal state const [selectedEvent, setSelectedEvent] = useState<{ @@ -575,14 +574,24 @@ function AgentView({ }, [agentState]); const handleSendMessage = async (content: string) => { - await sendMessage(content); + try { + await sendMessage(content); + } catch (err) { + console.error("[AgentView] Failed to send message:", err); + showError("Failed to send message. Please try again."); + } }; const handleConfirmDeleteAgent = async () => { if (!selectedAgent) return; - await deleteAgent(selectedAgent.id); - await refreshAgents(); - navigate(`/${agencyId}`); + try { + await deleteAgent(selectedAgent.id); + await refreshAgents(); + navigate(`/${agencyId}`); + } catch (err) { + console.error("[AgentView] Failed to delete agent:", err); + showError("Failed to delete agent. Please try again."); + } }; // Render content based on active tab @@ -823,9 +832,10 @@ function HomeRoute({ }) { const { agencies } = useAgencies(); const { agents, blueprints, spawnAgent, getOrCreateMind, sendMessageToAgent } = useAgency(agencyId); - const { items: activityItems, addUserMessage, subscribeToAgent } = useActivityFeed(agencyId); + const { items: activityItems, addUserMessage, removeUserMessage, subscribeToAgent } = useActivityFeed(agencyId); const { metrics } = useAgencyMetrics(agencyId); const [, navigate] = useLocation(); + const { showError } = useToast(); const agency = agencies.find((a) => a.id === agencyId); @@ -869,59 +879,81 @@ function HomeRoute({ // Handle sending message to agent - stays in command center view const handleSendMessage = useCallback( async (target: string, message: string) => { - // Find or create the target agent - let targetAgentId: string; - let agentType = target; - - if (target === "_agency-mind") { - // Get or create the agency mind - targetAgentId = await getOrCreateMind(); - agentType = "_agency-mind"; - } else { - // Find existing agent - const agent = agents.find((a) => a.id === target); - if (!agent) { - console.error("Agent not found:", target); - return; + let optimisticMessageId: string | undefined; + + try { + // Find or create the target agent + let targetAgentId: string; + let agentType = target; + + if (target === "_agency-mind") { + // Get or create the agency mind + targetAgentId = await getOrCreateMind(); + agentType = "_agency-mind"; + } else { + // Find existing agent + const agent = agents.find((a) => a.id === target); + if (!agent) { + console.error("[HomeRoute] Agent not found:", target); + return; + } + targetAgentId = agent.id; + agentType = agent.agentType; } - targetAgentId = agent.id; - agentType = agent.agentType; - } - // Register agent type for activity feed display - subscribeToAgent(targetAgentId, agentType); - - // Add optimistic update to activity feed - addUserMessage(target, message, targetAgentId); - - // Actually send the message to the agent - // Response will come via agency WebSocket - no polling needed - await sendMessageToAgent(targetAgentId, message); + // Register agent type for activity feed display + subscribeToAgent(targetAgentId, agentType); + + // Add optimistic update to activity feed (returns ID for rollback) + optimisticMessageId = addUserMessage(target, message, targetAgentId); + + // Actually send the message to the agent + // Response will come via agency WebSocket - no polling needed + await sendMessageToAgent(targetAgentId, message); + } catch (err) { + // Rollback optimistic update on failure + if (optimisticMessageId) { + removeUserMessage(optimisticMessageId); + } + console.error("[HomeRoute] Failed to send message:", err); + showError("Failed to send message. Please try again."); + } }, - [agents, getOrCreateMind, addUserMessage, sendMessageToAgent, subscribeToAgent] + [agents, getOrCreateMind, addUserMessage, removeUserMessage, sendMessageToAgent, subscribeToAgent, showError] ); // Handle creating new agent from blueprint // If message is provided, send it to the agent and stay in command center const handleCreateAgent = useCallback( async (blueprintName: string, message?: string) => { - const agent = await spawnAgent(blueprintName); - - if (message) { - // Register agent type for activity feed display - subscribeToAgent(agent.id, blueprintName); - - // Send message and stay in command center - addUserMessage(blueprintName, message, agent.id); - - // Send the message - response will come via agency WebSocket - await sendMessageToAgent(agent.id, message); - } else { - // Navigate to agent page when no initial message - navigate(`/${agencyId}/agent/${agent.id}`); + let optimisticMessageId: string | undefined; + + try { + const agent = await spawnAgent(blueprintName); + + if (message) { + // Register agent type for activity feed display + subscribeToAgent(agent.id, blueprintName); + + // Send message and stay in command center (returns ID for rollback) + optimisticMessageId = addUserMessage(blueprintName, message, agent.id); + + // Send the message - response will come via agency WebSocket + await sendMessageToAgent(agent.id, message); + } else { + // Navigate to agent page when no initial message + navigate(`/${agencyId}/agent/${agent.id}`); + } + } catch (err) { + // Rollback optimistic update on failure + if (optimisticMessageId) { + removeUserMessage(optimisticMessageId); + } + console.error("[HomeRoute] Failed to create agent:", err); + showError("Failed to create agent. Please try again."); } }, - [agencyId, spawnAgent, navigate, addUserMessage, sendMessageToAgent, subscribeToAgent] + [agencyId, spawnAgent, navigate, addUserMessage, removeUserMessage, sendMessageToAgent, subscribeToAgent, showError] ); return ( @@ -996,6 +1028,7 @@ function MainContent({ export default function App() { const [location, navigate] = useLocation(); + const { showError } = useToast(); // Auth state const [isLocked, setIsLocked] = useState(false); @@ -1082,10 +1115,11 @@ export default function App() { setIsMindOpen(true); } catch (err) { console.error("Failed to get or create mind:", err); + showError("Failed to open Agency Mind. Please try again."); } finally { setIsMindLoading(false); } - }, [agencyId, mindAgentId, getOrCreateMind]); + }, [agencyId, mindAgentId, getOrCreateMind, showError]); // Derive agent status const agentStatus = useMemo(() => { @@ -1121,10 +1155,15 @@ export default function App() { // Handlers const handleCreateAgency = async (name?: string) => { if (name) { - const agency = await createAgency(name); - navigate(`/${agency.id}`); - setShowAgencyModal(false); - setNewAgencyName(""); + try { + const agency = await createAgency(name); + navigate(`/${agency.id}`); + setShowAgencyModal(false); + setNewAgencyName(""); + } catch (err) { + console.error("[App] Failed to create agency:", err); + showError("Failed to create agency. Please try again."); + } } else { setShowAgencyModal(true); } @@ -1132,9 +1171,14 @@ export default function App() { const handleCreateAgent = async (agentType?: string) => { if (agentType && agencyId) { - const agent = await spawnAgent(agentType); - navigate(`/${agencyId}/agent/${agent.id}`); - setShowBlueprintPicker(false); + try { + const agent = await spawnAgent(agentType); + navigate(`/${agencyId}/agent/${agent.id}`); + setShowBlueprintPicker(false); + } catch (err) { + console.error("[App] Failed to create agent:", err); + showError("Failed to create agent. Please try again."); + } } else { setShowBlueprintPicker(true); } @@ -1244,8 +1288,12 @@ export default function App() { createRoot(document.getElementById("root")!).render( - - - + + + + + + + ); diff --git a/examples/personal-hub/ui/components/ChatView.tsx b/examples/personal-hub/ui/components/ChatView.tsx index 49b3a91..ae756d6 100644 --- a/examples/personal-hub/ui/components/ChatView.tsx +++ b/examples/personal-hub/ui/components/ChatView.tsx @@ -1,24 +1,7 @@ import { useState, useRef, useEffect, useMemo } from "react"; import { cn } from "../lib/utils"; import { Button } from "./Button"; - -// Types -interface ToolCall { - id: string; - name: string; - args: Record; - result?: unknown; - status: "pending" | "running" | "done" | "error"; -} - -interface Message { - id: string; - role: "user" | "assistant" | "system"; - content: string; - timestamp: string; - toolCalls?: ToolCall[]; - reasoning?: string; -} +import { formatTime, type Message, type ToolCall } from "./shared"; interface ChatViewProps { messages: Message[]; @@ -28,24 +11,6 @@ interface ChatViewProps { placeholder?: string; } -function formatTime(timestamp: string): string { - const date = new Date(timestamp); - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - const isYesterday = date.toDateString() === yesterday.toDateString(); - - const time = date.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit" - }); - - if (isToday) return time; - if (isYesterday) return `Yesterday ${time}`; - return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " + time; -} - function ReasoningBlock({ reasoning }: { reasoning: string }) { const [expanded, setExpanded] = useState(false); @@ -320,5 +285,3 @@ export function ChatView({
); } - -export type { Message, ToolCall }; diff --git a/examples/personal-hub/ui/components/ErrorBoundary.tsx b/examples/personal-hub/ui/components/ErrorBoundary.tsx new file mode 100644 index 0000000..bf56ed0 --- /dev/null +++ b/examples/personal-hub/ui/components/ErrorBoundary.tsx @@ -0,0 +1,100 @@ +import { Component, type ReactNode } from "react"; +import { Button } from "./Button"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +/** + * Error boundary component that catches rendering errors and displays a fallback UI. + * Prevents the entire app from crashing due to errors in child components. + */ +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + console.error("[ErrorBoundary] Caught error:", error); + console.error("[ErrorBoundary] Error info:", errorInfo); + } + + handleRetry = (): void => { + this.setState({ hasError: false, error: null }); + }; + + handleReload = (): void => { + window.location.reload(); + }; + + render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+
+
[!]
+

+ SYSTEM_ERROR +

+

+ SOMETHING WENT WRONG +

+
+ + {this.state.error && ( +
+

+ ERROR_MESSAGE: +

+

+ {this.state.error.message || "Unknown error"} +

+
+ )} + +
+ + +
+ +
+ SYS.STATUS: ERROR | RECOVERY: AVAILABLE +
+
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/examples/personal-hub/ui/components/SettingsView.tsx b/examples/personal-hub/ui/components/SettingsView.tsx index 18097b2..97153a8 100644 --- a/examples/personal-hub/ui/components/SettingsView.tsx +++ b/examples/personal-hub/ui/components/SettingsView.tsx @@ -6,6 +6,7 @@ import { LayerCard, LayerCardContent, LayerCardFooter } from "./LayerCard"; import { ConfirmModal } from "./ConfirmModal"; import { BlueprintEditor } from "./BlueprintEditor"; import { FilesView } from "./FilesView"; +import type { MemoryDisk } from "./shared"; import type { AgentBlueprint, AgentSchedule, @@ -19,12 +20,6 @@ import type { AddMcpServerRequest, } from "agents-hub/client"; -export interface MemoryDisk { - name: string; - description?: string; - size?: number; -} - interface SettingsViewProps { agencyId: string | null; agencyName?: string; diff --git a/examples/personal-hub/ui/components/Toast.tsx b/examples/personal-hub/ui/components/Toast.tsx new file mode 100644 index 0000000..3fb2630 --- /dev/null +++ b/examples/personal-hub/ui/components/Toast.tsx @@ -0,0 +1,135 @@ +import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react"; +import { cn } from "../lib/utils"; + +// Toast types +type ToastType = "error" | "success" | "info"; + +interface Toast { + id: string; + message: string; + type: ToastType; +} + +interface ToastContextValue { + showToast: (message: string, type?: ToastType) => void; + showError: (message: string) => void; +} + +const ToastContext = createContext(null); + +// Duration before toast auto-dismisses (ms) +const TOAST_DURATION = 4000; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((message: string, type: ToastType = "info") => { + const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + setToasts((prev) => [...prev, { id, message, type }]); + }, []); + + const showError = useCallback((message: string) => { + showToast(message, "error"); + }, [showToast]); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + return ( + + {children} + + + ); +} + +export function useToast(): ToastContextValue { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToast must be used within a ToastProvider"); + } + return context; +} + +// Toast container renders at bottom-right of screen +function ToastContainer({ + toasts, + onRemove, +}: { + toasts: Toast[]; + onRemove: (id: string) => void; +}) { + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ); +} + +function ToastItem({ + toast, + onRemove, +}: { + toast: Toast; + onRemove: (id: string) => void; +}) { + const [isExiting, setIsExiting] = useState(false); + + useEffect(() => { + const exitTimer = setTimeout(() => { + setIsExiting(true); + }, TOAST_DURATION - 300); + + const removeTimer = setTimeout(() => { + onRemove(toast.id); + }, TOAST_DURATION); + + return () => { + clearTimeout(exitTimer); + clearTimeout(removeTimer); + }; + }, [toast.id, onRemove]); + + const typeStyles: Record = { + error: "bg-red-500/90 border-red-400 text-white", + success: "bg-green-500/90 border-green-400 text-white", + info: "bg-gray-800/90 border-gray-600 text-gray-100", + }; + + const typeIcons: Record = { + error: "!", + success: "\u2713", + info: "i", + }; + + return ( +
+ + {typeIcons[toast.type]} + + {toast.message} + +
+ ); +} diff --git a/examples/personal-hub/ui/components/TodosView.tsx b/examples/personal-hub/ui/components/TodosView.tsx index ef6f7a1..62f3f21 100644 --- a/examples/personal-hub/ui/components/TodosView.tsx +++ b/examples/personal-hub/ui/components/TodosView.tsx @@ -1,19 +1,6 @@ import { useState } from "react"; import { cn } from "../lib/utils"; - -// Types -type TodoStatus = "pending" | "in_progress" | "done" | "blocked"; -type TodoPriority = "low" | "medium" | "high"; - -interface Todo { - id: string; - title: string; - description?: string; - status: TodoStatus; - priority: TodoPriority; - createdAt: string; - completedAt?: string; -} +import type { Todo, TodoStatus, TodoPriority } from "./shared"; interface TodosViewProps { todos: Todo[]; diff --git a/examples/personal-hub/ui/components/index.ts b/examples/personal-hub/ui/components/index.ts index 250ec6b..2082253 100644 --- a/examples/personal-hub/ui/components/index.ts +++ b/examples/personal-hub/ui/components/index.ts @@ -21,6 +21,12 @@ export { ConfirmModal } from "./ConfirmModal"; export { BlueprintEditor } from "./BlueprintEditor"; export { VarEditor } from "./VarEditor"; +// Error handling +export { ErrorBoundary } from "./ErrorBoundary"; + +// Toast notifications +export { ToastProvider, useToast } from "./Toast"; + // Re-export types from shared export type { AgencyMeta, diff --git a/examples/personal-hub/ui/components/shared/constants.ts b/examples/personal-hub/ui/components/shared/constants.ts index 37d8775..c2caff8 100644 --- a/examples/personal-hub/ui/components/shared/constants.ts +++ b/examples/personal-hub/ui/components/shared/constants.ts @@ -3,7 +3,7 @@ */ import type { AgentStatus } from "./types"; -// Status colors (background) +// Status colors (background) - used by Sidebar export const STATUS_BG_COLORS: Record = { running: "bg-[#00aaff]", paused: "bg-[#ffaa00]", @@ -11,92 +11,3 @@ export const STATUS_BG_COLORS: Record = { error: "bg-[#ff0000]", idle: "bg-white/30", }; - -// Status colors (text) -export const STATUS_TEXT_COLORS: Record = { - running: "text-[#00aaff]", - paused: "text-[#ffaa00]", - done: "text-[#00ff00]", - error: "text-[#ff0000]", - idle: "text-white/50", -}; - -// Status colors (border) -export const STATUS_BORDER_COLORS: Record = { - running: "border-[#00aaff]", - paused: "border-[#ffaa00]", - done: "border-[#00ff00]", - error: "border-[#ff0000]", - idle: "border-white/30", -}; - -// Status labels with colors for ContentHeader -export const STATUS_LABELS: Record = { - running: { label: "RUNNING", color: "text-[#00aaff]", borderColor: "border-[#00aaff]" }, - paused: { label: "PAUSED", color: "text-[#ffaa00]", borderColor: "border-[#ffaa00]" }, - done: { label: "COMPLETE", color: "text-[#00ff00]", borderColor: "border-[#00ff00]" }, - error: { label: "ERROR", color: "text-[#ff0000]", borderColor: "border-[#ff0000]" }, - idle: { label: "IDLE", color: "text-white/50", borderColor: "border-white/30" }, -}; - -// Event filter types for TraceView -export type EventFilter = "model" | "tool" | "status" | "tick" | "context"; - -// Filter configuration for TraceView -export const FILTER_CONFIG: Record = { - model: { - label: "MODEL", - tag: "[MODEL]", - events: ["model.started"], - }, - tool: { - label: "TOOLS", - tag: "[TOOL]", - events: ["tool.output", "tool.error"], - }, - status: { - label: "STATUS", - tag: "[SYS]", - events: ["run.paused", "run.resumed", "agent.completed", "agent.error"], - }, - context: { - label: "CONTEXT", - tag: "[CTX]", - events: ["context.summarized"], - }, - tick: { - label: "TICKS", - tag: "[TICK]", - events: ["run.tick"], - }, -}; - -// Event configuration for TraceView -export const EVENT_CONFIG: Record = { - "run.tick": { tag: "[TICK]", color: "text-white/30", label: "TICK" }, - "model.started": { tag: "[MODEL]", color: "text-white/50", label: "MODEL" }, - "tool.output": { tag: "[TOOL]", color: "text-[#00ff00]", label: "TOOL" }, - "tool.error": { tag: "[TOOL]", color: "text-[#ff0000]", label: "TOOL_ERR" }, - "run.paused": { tag: "[SYS]", color: "text-[#ffaa00]", label: "PAUSED" }, - "run.resumed": { tag: "[SYS]", color: "text-[#00aaff]", label: "RESUMED" }, - "agent.completed": { tag: "[SYS]", color: "text-[#00ff00]", label: "DONE" }, - "agent.error": { tag: "[SYS]", color: "text-[#ff0000]", label: "ERROR" }, - "subagent.spawned": { tag: "[SUB]", color: "text-[#00aaff]", label: "SPAWN" }, - "subagent.completed": { tag: "[SUB]", color: "text-[#00aaff]", label: "RETURN" }, - "task.batch": { tag: "[TASK]", color: "text-[#00aaff]", label: "SUBAGENTS" }, - "context.summarized": { tag: "[CTX]", color: "text-amber-400/70", label: "SUMMARIZED" }, -}; - -export const DEFAULT_EVENT_CONFIG = { - tag: "[EVT]", - color: "text-white/40", - label: "EVENT", -}; - -// Tab configuration for agent views -export const AGENT_TABS = [ - { id: "chat" as const, label: "CHAT", icon: "[>]" }, - { id: "trace" as const, label: "TRACE", icon: "[~]" }, - { id: "files" as const, label: "FILES", icon: "[/]" }, - { id: "todos" as const, label: "TASKS", icon: "[*]" }, -]; diff --git a/examples/personal-hub/ui/components/shared/types.ts b/examples/personal-hub/ui/components/shared/types.ts index 65c1d59..14d2809 100644 --- a/examples/personal-hub/ui/components/shared/types.ts +++ b/examples/personal-hub/ui/components/shared/types.ts @@ -55,12 +55,17 @@ export interface Message { } // Todo item type +export type TodoStatus = "pending" | "in_progress" | "done" | "blocked"; +export type TodoPriority = "low" | "medium" | "high"; + export interface Todo { id: string; title: string; - status: "pending" | "in_progress" | "done"; - priority: "low" | "medium" | "high"; + description?: string; + status: TodoStatus; + priority: TodoPriority; createdAt: string; + completedAt?: string; } // Activity item for unified feed diff --git a/examples/personal-hub/ui/hooks/useAgentSystem.ts b/examples/personal-hub/ui/hooks/useAgentSystem.ts index 893d552..eca8e18 100644 --- a/examples/personal-hub/ui/hooks/useAgentSystem.ts +++ b/examples/personal-hub/ui/hooks/useAgentSystem.ts @@ -74,11 +74,21 @@ export const queryKeys = { // ============================================================================ type AgencyEventListener = (event: AgencyWebSocketEvent) => void; +type ConnectionStatusListener = (connected: boolean) => void; + +// Reconnection configuration +const RECONNECT_BASE_DELAY = 1000; // 1 second +const RECONNECT_MAX_DELAY = 30000; // 30 seconds +const RECONNECT_MAX_ATTEMPTS = 10; interface AgencyWsManager { connection: AgencyWebSocket | null; listeners: Set; + statusListeners: Set; connecting: boolean; + reconnectAttempts: number; + reconnectTimeout: ReturnType | null; + intentionallyClosed: boolean; } const agencyWsManagers = new Map(); @@ -89,23 +99,75 @@ function getOrCreateAgencyWsManager(agencyId: string): AgencyWsManager { manager = { connection: null, listeners: new Set(), + statusListeners: new Set(), connecting: false, + reconnectAttempts: 0, + reconnectTimeout: null, + intentionallyClosed: false, }; agencyWsManagers.set(agencyId, manager); } return manager; } +function notifyConnectionStatus(manager: AgencyWsManager, connected: boolean): void { + for (const listener of manager.statusListeners) { + try { + listener(connected); + } catch (e) { + console.error("[AgencyWS] Status listener error:", e); + } + } +} + +function scheduleReconnect(agencyId: string): void { + const manager = getOrCreateAgencyWsManager(agencyId); + + // Don't reconnect if intentionally closed or no listeners + if (manager.intentionallyClosed || manager.listeners.size === 0) { + return; + } + + // Don't reconnect if max attempts reached + if (manager.reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) { + console.error(`[AgencyWS] Max reconnect attempts (${RECONNECT_MAX_ATTEMPTS}) reached for agency ${agencyId}`); + return; + } + + // Calculate delay with exponential backoff + const delay = Math.min( + RECONNECT_BASE_DELAY * Math.pow(2, manager.reconnectAttempts), + RECONNECT_MAX_DELAY + ); + + console.log(`[AgencyWS] Scheduling reconnect for agency ${agencyId} in ${delay}ms (attempt ${manager.reconnectAttempts + 1})`); + + manager.reconnectTimeout = setTimeout(() => { + manager.reconnectTimeout = null; + manager.reconnectAttempts++; + connectAgencyWs(agencyId); + }, delay); +} + function connectAgencyWs(agencyId: string): void { const manager = getOrCreateAgencyWsManager(agencyId); if (manager.connection || manager.connecting) return; + // Clear any pending reconnect + if (manager.reconnectTimeout) { + clearTimeout(manager.reconnectTimeout); + manager.reconnectTimeout = null; + } + manager.connecting = true; + manager.intentionallyClosed = false; const client = getClient().agency(agencyId); const ws = client.connect({ onOpen: () => { manager.connecting = false; + manager.reconnectAttempts = 0; // Reset on successful connection + notifyConnectionStatus(manager, true); }, onEvent: (event) => { // Notify all listeners @@ -120,10 +182,22 @@ function connectAgencyWs(agencyId: string): void { onClose: () => { manager.connection = null; manager.connecting = false; + notifyConnectionStatus(manager, false); + + // Schedule reconnect if not intentionally closed + if (!manager.intentionallyClosed) { + scheduleReconnect(agencyId); + } }, onError: () => { manager.connection = null; manager.connecting = false; + notifyConnectionStatus(manager, false); + + // Schedule reconnect + if (!manager.intentionallyClosed) { + scheduleReconnect(agencyId); + } }, }); @@ -146,14 +220,86 @@ function subscribeToAgencyEvents( return () => { manager.listeners.delete(listener); - // If no more listeners, close connection - if (manager.listeners.size === 0 && manager.connection) { - manager.connection.close(); - manager.connection = null; + // If no more listeners, close connection and cleanup + if (manager.listeners.size === 0) { + manager.intentionallyClosed = true; + + // Clear any pending reconnect + if (manager.reconnectTimeout) { + clearTimeout(manager.reconnectTimeout); + manager.reconnectTimeout = null; + } + + if (manager.connection) { + manager.connection.close(); + manager.connection = null; + } } }; } +/** + * Subscribe to connection status changes for an agency WebSocket + */ +function subscribeToConnectionStatus( + agencyId: string, + listener: ConnectionStatusListener +): () => void { + const manager = getOrCreateAgencyWsManager(agencyId); + manager.statusListeners.add(listener); + + // Immediately notify current status + const isConnected = !!manager.connection && !manager.connecting; + listener(isConnected); + + return () => { + manager.statusListeners.delete(listener); + }; +} + +/** + * Get current connection status for an agency + */ +function getAgencyConnectionStatus(agencyId: string): { connected: boolean; reconnecting: boolean } { + const manager = agencyWsManagers.get(agencyId); + if (!manager) { + return { connected: false, reconnecting: false }; + } + return { + connected: !!manager.connection && !manager.connecting, + reconnecting: manager.reconnectAttempts > 0 && !manager.intentionallyClosed, + }; +} + +/** + * Manually trigger reconnection for an agency WebSocket + */ +function reconnectAgencyWs(agencyId: string): void { + const manager = agencyWsManagers.get(agencyId); + if (!manager) return; + + // Reset state for manual reconnect + manager.intentionallyClosed = false; + manager.reconnectAttempts = 0; + + // Clear any pending reconnect + if (manager.reconnectTimeout) { + clearTimeout(manager.reconnectTimeout); + manager.reconnectTimeout = null; + } + + // Close existing connection if any + if (manager.connection) { + manager.connection.close(); + manager.connection = null; + } + + // Connect if there are listeners + if (manager.listeners.size > 0) { + connectAgencyWs(agencyId); + } +} + // ============================================================================ // useAgencies - List and manage agencies // ============================================================================ @@ -279,13 +425,25 @@ export function useAgency(agencyId: string | null) { [agencyId] ); + // Helper to get client with error if not available + const requireClient = useCallback(() => { + if (!client) throw new Error("No agency selected"); + return client; + }, [client]); + // Stable function references for filesystem operations const listDirectory = useCallback( - (path: string = "/") => client!.listDirectory(path), + (path: string = "/") => { + if (!client) return Promise.reject(new Error("No agency selected")); + return client.listDirectory(path); + }, [client] ); const readFile = useCallback( - (path: string) => client!.readFile(path), + (path: string) => { + if (!client) return Promise.reject(new Error("No agency selected")); + return client.readFile(path); + }, [client] ); const normalizeFsPath = useCallback( @@ -298,77 +456,87 @@ export function useAgency(agencyId: string | null) { ); const writeFile = useCallback( (path: string, content: string) => { + if (!client) return Promise.reject(new Error("No agency selected")); if (isProtectedFile(path)) { return Promise.reject(new Error("`.agency.json` is read-only")); } - return client!.writeFile(path, content); + return client.writeFile(path, content); }, [client, isProtectedFile] ); const deleteFile = useCallback( (path: string) => { + if (!client) return Promise.reject(new Error("No agency selected")); if (isProtectedFile(path)) { return Promise.reject(new Error("`.agency.json` is read-only")); } - return client!.deleteFile(path); + return client.deleteFile(path); }, [client, isProtectedFile] ); - // Queries + // Queries - enabled flag ensures client exists, but we add defensive checks const { data: agents = [], isLoading: loading, error, } = useQuery({ - queryKey: queryKeys.agents(agencyId!), + queryKey: queryKeys.agents(agencyId || "_none"), queryFn: async () => { - const { agents } = await client!.listAgents(); + if (!client) throw new Error("No agency selected"); + const { agents } = await client.listAgents(); return agents; }, - enabled: !!agencyId, + enabled: !!agencyId && !!client, }); const { data: blueprints = [] } = useQuery({ - queryKey: queryKeys.blueprints(agencyId!), + queryKey: queryKeys.blueprints(agencyId || "_none"), queryFn: async () => { - const { blueprints } = await client!.listBlueprints(); + if (!client) throw new Error("No agency selected"); + const { blueprints } = await client.listBlueprints(); return blueprints; }, - enabled: !!agencyId, + enabled: !!agencyId && !!client, }); const { data: schedules = [] } = useQuery({ - queryKey: queryKeys.schedules(agencyId!), + queryKey: queryKeys.schedules(agencyId || "_none"), queryFn: async () => { - const { schedules } = await client!.listSchedules(); + if (!client) throw new Error("No agency selected"); + const { schedules } = await client.listSchedules(); return schedules; }, - enabled: !!agencyId, + enabled: !!agencyId && !!client, }); const { data: vars = {} } = useQuery({ - queryKey: queryKeys.vars(agencyId!), + queryKey: queryKeys.vars(agencyId || "_none"), queryFn: async () => { - const { vars } = await client!.getVars(); + if (!client) throw new Error("No agency selected"); + const { vars } = await client.getVars(); return vars; }, - enabled: !!agencyId, + enabled: !!agencyId && !!client, }); const { data: memoryDisks = [] } = useQuery({ - queryKey: queryKeys.memoryDisks(agencyId!), - queryFn: () => fetchMemoryDisks(agencyId!), + queryKey: queryKeys.memoryDisks(agencyId || "_none"), + queryFn: () => { + if (!agencyId) throw new Error("No agency selected"); + return fetchMemoryDisks(agencyId); + }, enabled: !!agencyId, }); const { data: mcpServers = [] } = useQuery({ - queryKey: queryKeys.mcpServers(agencyId!), + queryKey: queryKeys.mcpServers(agencyId || "_none"), queryFn: async () => { - const { servers } = await client!.listMcpServers(); + if (!client) throw new Error("No agency selected"); + const { servers } = await client.listMcpServers(); return servers; }, - enabled: !!agencyId, + enabled: !!agencyId && !!client, }); // Subscribe to live MCP server updates via agency WebSocket @@ -408,12 +576,13 @@ export function useAgency(agencyId: string | null) { return unsubscribe; }, [agencyId, queryClient]); - // Mutations + // Mutations - all use requireClient() for safe client access const spawnMutation = useMutation({ - mutationFn: async (agentType: string) => client!.spawnAgent({ agentType }), + mutationFn: async (agentType: string) => requireClient().spawnAgent({ agentType }), onSuccess: (newAgent) => { + if (!agencyId) return; queryClient.setQueryData( - queryKeys.agents(agencyId!), + queryKeys.agents(agencyId), (old) => (old ? [...old, newAgent] : [newAgent]) ); }, @@ -421,64 +590,70 @@ export function useAgency(agencyId: string | null) { const scheduleMutation = useMutation({ mutationFn: async (request: CreateScheduleRequest) => { - const { schedule } = await client!.createSchedule(request); + const { schedule } = await requireClient().createSchedule(request); return schedule; }, - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.schedules(agencyId!), - }), + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.schedules(agencyId) }); + }, }); const deleteScheduleMutation = useMutation({ - mutationFn: (scheduleId: string) => client!.deleteSchedule(scheduleId), - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.schedules(agencyId!), - }), + mutationFn: (scheduleId: string) => requireClient().deleteSchedule(scheduleId), + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.schedules(agencyId) }); + }, }); const pauseScheduleMutation = useMutation({ - mutationFn: (scheduleId: string) => client!.pauseSchedule(scheduleId), - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.schedules(agencyId!), - }), + mutationFn: (scheduleId: string) => requireClient().pauseSchedule(scheduleId), + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.schedules(agencyId) }); + }, }); const deleteAgentMutation = useMutation({ - mutationFn: (agentId: string) => client!.deleteAgent(agentId), - onSuccess: () => - queryClient.invalidateQueries({ queryKey: queryKeys.agents(agencyId!) }), + mutationFn: (agentId: string) => requireClient().deleteAgent(agentId), + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.agents(agencyId) }); + }, }); const deleteAgencyMutation = useMutation({ - mutationFn: () => client!.deleteAgency(), + mutationFn: () => requireClient().deleteAgency(), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.agencies }); }, }); const resumeScheduleMutation = useMutation({ - mutationFn: (scheduleId: string) => client!.resumeSchedule(scheduleId), - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.schedules(agencyId!), - }), + mutationFn: (scheduleId: string) => requireClient().resumeSchedule(scheduleId), + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.schedules(agencyId) }); + }, }); const setVarMutation = useMutation({ mutationFn: async ({ key, value }: { key: string; value: unknown }) => { - await client!.setVar(key, value); + await requireClient().setVar(key, value); + }, + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.vars(agencyId) }); }, - onSuccess: () => - queryClient.invalidateQueries({ queryKey: queryKeys.vars(agencyId!) }), }); const deleteVarMutation = useMutation({ - mutationFn: (key: string) => client!.deleteVar(key), - onSuccess: () => - queryClient.invalidateQueries({ queryKey: queryKeys.vars(agencyId!) }), + mutationFn: (key: string) => requireClient().deleteVar(key), + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.vars(agencyId) }); + }, }); const createMemoryDiskMutation = useMutation({ @@ -498,15 +673,15 @@ export function useAgency(agencyId: string | null) { hasEmbeddings: false, entries: entries?.map((content) => ({ content })) ?? [], }; - await client!.writeFile( + await requireClient().writeFile( `/shared/memories/${name}.idz`, JSON.stringify(idz) ); }, - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.memoryDisks(agencyId!), - }), + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.memoryDisks(agencyId) }); + }, }); const importMemoryDiskMutation = useMutation({ @@ -514,21 +689,21 @@ export function useAgency(agencyId: string | null) { const content = await file.text(); const data = JSON.parse(content) as { name?: string }; const name = data.name || file.name.replace(/\.(idz|json)$/, ""); - await client!.writeFile(`/shared/memories/${name}.idz`, content); + await requireClient().writeFile(`/shared/memories/${name}.idz`, content); + }, + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.memoryDisks(agencyId) }); }, - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.memoryDisks(agencyId!), - }), }); const deleteMemoryDiskMutation = useMutation({ mutationFn: (name: string) => - client!.deleteFile(`/shared/memories/${name}.idz`), - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.memoryDisks(agencyId!), - }), + requireClient().deleteFile(`/shared/memories/${name}.idz`), + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.memoryDisks(agencyId) }); + }, }); const blueprintMutation = useMutation({ @@ -536,49 +711,49 @@ export function useAgency(agencyId: string | null) { blueprint: | Omit | AgentBlueprint - ) => client!.createBlueprint(blueprint), - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.blueprints(agencyId!), - }), + ) => requireClient().createBlueprint(blueprint), + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.blueprints(agencyId) }); + }, }); const deleteBlueprintMutation = useMutation({ - mutationFn: (name: string) => client!.deleteBlueprint(name), - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.blueprints(agencyId!), - }), + mutationFn: (name: string) => requireClient().deleteBlueprint(name), + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.blueprints(agencyId) }); + }, }); const addMcpServerMutation = useMutation({ mutationFn: async (request: AddMcpServerRequest) => { - const { server } = await client!.addMcpServer(request); + const { server } = await requireClient().addMcpServer(request); return server; }, - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.mcpServers(agencyId!), - }), + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.mcpServers(agencyId) }); + }, }); const removeMcpServerMutation = useMutation({ - mutationFn: (serverId: string) => client!.removeMcpServer(serverId), - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.mcpServers(agencyId!), - }), + mutationFn: (serverId: string) => requireClient().removeMcpServer(serverId), + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.mcpServers(agencyId) }); + }, }); const retryMcpServerMutation = useMutation({ mutationFn: async (serverId: string) => { - const { server } = await client!.retryMcpServer(serverId); + const { server } = await requireClient().retryMcpServer(serverId); return server; }, - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.mcpServers(agencyId!), - }), + onSuccess: () => { + if (!agencyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.mcpServers(agencyId) }); + }, }); /** @@ -606,22 +781,26 @@ export function useAgency(agencyId: string | null) { loading, error: error as Error | null, sendMessageToAgent, - refreshAgents: () => - queryClient.invalidateQueries({ queryKey: queryKeys.agents(agencyId!) }), - refreshBlueprints: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.blueprints(agencyId!), - }), - refreshSchedules: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.schedules(agencyId!), - }), - refreshVars: () => - queryClient.invalidateQueries({ queryKey: queryKeys.vars(agencyId!) }), - refreshMemoryDisks: () => - queryClient.invalidateQueries({ - queryKey: queryKeys.memoryDisks(agencyId!), - }), + refreshAgents: () => { + if (!agencyId) return Promise.resolve(); + return queryClient.invalidateQueries({ queryKey: queryKeys.agents(agencyId) }); + }, + refreshBlueprints: () => { + if (!agencyId) return Promise.resolve(); + return queryClient.invalidateQueries({ queryKey: queryKeys.blueprints(agencyId) }); + }, + refreshSchedules: () => { + if (!agencyId) return Promise.resolve(); + return queryClient.invalidateQueries({ queryKey: queryKeys.schedules(agencyId) }); + }, + refreshVars: () => { + if (!agencyId) return Promise.resolve(); + return queryClient.invalidateQueries({ queryKey: queryKeys.vars(agencyId) }); + }, + refreshMemoryDisks: () => { + if (!agencyId) return Promise.resolve(); + return queryClient.invalidateQueries({ queryKey: queryKeys.memoryDisks(agencyId) }); + }, // Note: refreshMcpServers removed - MCP updates come live via agency WebSocket spawnAgent: spawnMutation.mutateAsync, listDirectory, @@ -633,11 +812,11 @@ export function useAgency(agencyId: string | null) { pauseSchedule: pauseScheduleMutation.mutateAsync, resumeSchedule: resumeScheduleMutation.mutateAsync, triggerSchedule: async (scheduleId: string) => { - const { run } = await client!.triggerSchedule(scheduleId); + const { run } = await requireClient().triggerSchedule(scheduleId); return run; }, getScheduleRuns: async (scheduleId: string): Promise => { - const { runs } = await client!.getScheduleRuns(scheduleId); + const { runs } = await requireClient().getScheduleRuns(scheduleId); return runs; }, deleteAgent: deleteAgentMutation.mutateAsync, @@ -663,21 +842,23 @@ export function useAgency(agencyId: string | null) { * Returns the agent ID of the existing or newly spawned mind. */ getOrCreateMind: async (): Promise => { - if (!client) throw new Error("No agency selected"); + const c = requireClient(); // Check if mind agent already exists - const { agents: currentAgents } = await client.listAgents(); + const { agents: currentAgents } = await c.listAgents(); const existingMind = currentAgents.find( (a) => a.agentType === AGENCY_MIND_TYPE ); if (existingMind) return existingMind.id; // Spawn new mind agent - const newMind = await client.spawnAgent({ agentType: AGENCY_MIND_TYPE }); - queryClient.setQueryData( - queryKeys.agents(agencyId!), - (old) => (old ? [...old, newMind] : [newMind]) - ); + const newMind = await c.spawnAgent({ agentType: AGENCY_MIND_TYPE }); + if (agencyId) { + queryClient.setQueryData( + queryKeys.agents(agencyId), + (old) => (old ? [...old, newMind] : [newMind]) + ); + } return newMind.id; }, }; @@ -1019,7 +1200,7 @@ export function useAgent(agencyId: string | null, agentId: string | null) { }; }, [agencyId, agentId, fetchState, fetchEvents, handleAgencyEvent]); - // Send message + // Send message with optimistic update and rollback const sendMessage = useCallback( async (content: string) => { const agentClient = agentClientRef.current; @@ -1030,9 +1211,13 @@ export function useAgent(agencyId: string | null, agentId: string | null) { content, }; + // Capture previous state for rollback + let previousMessages: ChatMessage[] | null = null; + // Optimistically add user message setHookState((prev) => { if (!prev.state) return prev; + previousMessages = prev.state.messages || []; return { ...prev, state: { @@ -1042,8 +1227,26 @@ export function useAgent(agencyId: string | null, agentId: string | null) { }; }); - // Invoke agent - response will come via WebSocket events - await agentClient.invoke({ messages: [message] }); + try { + // Invoke agent - response will come via WebSocket events + await agentClient.invoke({ messages: [message] }); + } catch (error) { + // Rollback optimistic update on failure + if (previousMessages !== null) { + const rollbackMessages = previousMessages; + setHookState((prev) => { + if (!prev.state) return prev; + return { + ...prev, + state: { + ...prev.state, + messages: rollbackMessages, + }, + }; + }); + } + throw error; // Re-throw so caller can handle + } }, [] ); @@ -1065,6 +1268,13 @@ export function useAgent(agencyId: string | null, agentId: string | null) { [] ); + // Reconnect handler - uses the agency WS reconnect + const reconnect = useCallback(() => { + if (agencyId) { + reconnectAgencyWs(agencyId); + } + }, [agencyId]); + return { ...hookState, sendMessage, @@ -1072,7 +1282,7 @@ export function useAgent(agencyId: string | null, agentId: string | null) { approve, refresh: fetchState, refreshEvents: fetchEvents, - reconnect: () => {}, // No-op - agency WS handles reconnection + reconnect, }; } @@ -1080,19 +1290,8 @@ export function useAgent(agencyId: string | null, agentId: string | null) { // useActivityFeed - Aggregate activity across all agents in an agency // ============================================================================ -export interface ActivityItem { - id: string; - timestamp: string; - type: "message" | "agent_event" | "system"; - from?: string; - to?: string; - content?: string; - agentId?: string; - agentType?: string; - event?: string; - details?: string; - status?: "running" | "done" | "error"; -} +import type { ActivityItem } from "../components/shared/types"; +export type { ActivityItem }; export function useActivityFeed(agencyId: string | null) { const [items, setItems] = useState([]); @@ -1290,6 +1489,14 @@ export function useActivityFeed(agencyId: string | null) { // Update agent type lookup agentTypesRef.current.set(agentId, target); + + // Return the ID for potential rollback + return newItem.id; + }, []); + + // Remove a user message by ID (for rollback on failure) + const removeUserMessage = useCallback((messageId: string) => { + setItems((prev) => prev.filter((item) => item.id !== messageId)); }, []); // No longer needed - agency WS handles all agents automatically @@ -1307,6 +1514,7 @@ export function useActivityFeed(agencyId: string | null) { isLoading, refresh: fetchActivity, addUserMessage, + removeUserMessage, subscribeToAgent, }; } diff --git a/lib/runtime/plugins/index.ts b/lib/runtime/plugins/index.ts index 133e2c6..88f4d4c 100644 --- a/lib/runtime/plugins/index.ts +++ b/lib/runtime/plugins/index.ts @@ -3,3 +3,5 @@ export { logger } from "./logger"; export { hitl } from "./hitl"; export { planning, type Todo } from "./planning"; export { context } from "./context"; +export { subagents } from "./subagents"; +export { subagentReporter } from "./subagent-reporter"; diff --git a/lib/runtime/plugins/subagent-reporter.ts b/lib/runtime/plugins/subagent-reporter.ts new file mode 100644 index 0000000..152a3c1 --- /dev/null +++ b/lib/runtime/plugins/subagent-reporter.ts @@ -0,0 +1,49 @@ +import { getAgentByName } from "agents"; +import type { AgentPlugin } from "../types"; + +interface ParentInfo { + threadId: string; + token: string; + /** Custom action type for reporting back (default: subagent_result) */ + action?: string; +} + +export const subagentReporter: AgentPlugin = { + name: "subagent_reporter", + + async onRunComplete(ctx, { final }) { + // Get parent info from persisted meta + const parent = ctx.agent.vars.parent as ParentInfo | undefined; + + if (!parent?.threadId || !parent?.token) { + // Not a subagent, nothing to report + return; + } + + try { + const parentAgent = await getAgentByName( + ctx.agent.exports.HubAgent, + parent.threadId + ); + + // Send completion via action (supports custom action types for orchestrator) + const actionType = parent.action ?? "subagent_result"; + await parentAgent.fetch( + new Request("http://do/action", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + type: actionType, + token: parent.token, + childThreadId: ctx.agent.info.threadId, + report: final, + }), + }) + ); + } catch (e) { + console.error("Failed to report to parent:", e); + } + }, + + tags: ["subagent_reporter"], +}; diff --git a/lib/runtime/plugins/subagents.ts b/lib/runtime/plugins/subagents.ts new file mode 100644 index 0000000..ccecb08 --- /dev/null +++ b/lib/runtime/plugins/subagents.ts @@ -0,0 +1,472 @@ +import { getAgentByName } from "agents"; +import { tool, z } from "../tools"; +import { AgentEventType } from "../events"; +import type { AgentPlugin } from "../types"; + +const SubagentEventType = { + SPAWNED: "subagent.spawned", + COMPLETED: "subagent.completed", + MESSAGED: "subagent.messaged", +} as const; + +const TaskParams = z.object({ + description: z.string().describe("Task description for the subagent"), + subagentType: z.string().describe("Type of subagent to spawn"), +}); + +const MessageAgentParams = z.object({ + agentId: z.string().describe("The agentId from a previous task result"), + message: z.string().describe("Follow-up message to send to the agent"), +}); + +type SubagentRef = { + name: string; + description: string; +}; + +function renderOtherAgents(subagents: SubagentRef[]) { + return subagents.map((a) => `- ${a.name}: ${a.description}`).join("\n"); +} + +export const subagents: AgentPlugin = { + name: "subagents", + + async onInit(ctx) { + // Create our own tables for tracking subagents + ctx.agent.sqlite` + CREATE TABLE IF NOT EXISTS mw_waiting_subagents ( + token TEXT PRIMARY KEY, + child_thread_id TEXT NOT NULL, + tool_call_id TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS mw_subagent_links ( + child_thread_id TEXT PRIMARY KEY, + token TEXT NOT NULL, + agent_type TEXT, + status TEXT NOT NULL CHECK(status IN ('waiting','completed','canceled')), + created_at INTEGER NOT NULL, + completed_at INTEGER, + report TEXT, + tool_call_id TEXT + ); + `; + }, + + actions: { + async subagent_result(ctx, payload: unknown) { + const { token, childThreadId, report } = payload as { + token: string; + childThreadId: string; + report?: string; + }; + + const sql = ctx.agent.sqlite; + + // Pop waiter + const rows = sql`SELECT tool_call_id FROM mw_waiting_subagents WHERE token = ${token} AND child_thread_id = ${childThreadId}`; + if (!rows.length) { + throw new Error("unknown token"); + } + + const toolCallId = String(rows[0].tool_call_id); + sql`DELETE FROM mw_waiting_subagents WHERE token = ${token}`; + + // Update link status + sql`UPDATE mw_subagent_links SET status='completed', completed_at=${Date.now()}, report=${report ?? null} WHERE child_thread_id = ${childThreadId}`; + + // Append tool result with agentId for follow-up capability + const result = JSON.stringify({ + agentId: childThreadId, + result: report ?? "", + }); + ctx.agent.store.add({ role: "tool", toolCallId, content: result }); + + ctx.agent.emit(SubagentEventType.COMPLETED, { + childThreadId, + result: report, + }); + + // Check if all done + const remaining = sql`SELECT COUNT(*) as c FROM mw_waiting_subagents`; + + if (Number(remaining[0]?.c ?? 0) === 0) { + ctx.agent.runState.status = "running"; + ctx.agent.runState.reason = undefined; + ctx.agent.emit(AgentEventType.RUN_RESUMED, {}); + await ctx.agent.ensureScheduled(); + } + + return { ok: true }; + }, + + async cancel_subagents(ctx) { + const sql = ctx.agent.sqlite; + const waiters = sql`SELECT token, child_thread_id FROM mw_waiting_subagents`; + + for (const w of waiters) { + try { + const childAgent = await getAgentByName( + ctx.agent.exports.HubAgent, + String(w.child_thread_id) + ); + // Send cancel action to child + await childAgent.fetch( + new Request("http://do/action", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "cancel" }), + }) + ); + } catch (e) { + console.error(`Failed to cancel subagent ${w.child_thread_id}:`, e); + } + + // Mark as canceled + sql`UPDATE mw_subagent_links SET status='canceled', completed_at=${Date.now()} WHERE child_thread_id = ${w.child_thread_id}`; + } + + // Clear all waiters + sql`DELETE FROM mw_waiting_subagents`; + + return { ok: true }; + }, + }, + + state(ctx) { + // Expose subagent links in agent state + const sql = ctx.agent.sqlite; + const rows = sql`SELECT child_thread_id, token, agent_type, status, created_at, completed_at, report, tool_call_id + FROM mw_subagent_links ORDER BY created_at ASC`; + + const subagents = rows.map((r) => ({ + childThreadId: String(r.child_thread_id), + token: String(r.token ?? ""), + agentType: r.agent_type ? String(r.agent_type) : undefined, + status: String(r.status), + createdAt: Number(r.created_at ?? Date.now()), + completedAt: r.completed_at ? Number(r.completed_at) : undefined, + report: r.report ? String(r.report) : undefined, + toolCallId: r.tool_call_id ? String(r.tool_call_id) : undefined, + })); + + return { subagents }; + }, + + async beforeModel(ctx, plan) { + plan.addSystemPrompt(TASK_SYSTEM_PROMPT); + const subagentsConfig = ctx.agent.vars.SUBAGENTS as + | SubagentRef[] + | undefined; + const otherAgents = renderOtherAgents(subagentsConfig ?? []); + const taskDesc = TASK_TOOL_DESCRIPTION.replace( + "{other_agents}", + otherAgents + ); + + const taskTool = tool({ + name: "task", + description: taskDesc, + inputSchema: TaskParams, + execute: async (p, toolCtx) => { + const { description, subagentType } = p; + const token = crypto.randomUUID(); + const sql = ctx.agent.sqlite; + const parentAgentId = ctx.agent.info.threadId; + const vars = toolCtx.agent.vars; + + // Spawn child through Agency (creates parent-child relationship) + const agency = await getAgentByName( + toolCtx.agent.exports.Agency, + ctx.agent.info.agencyId + ); + + const spawnRes = await agency.fetch( + new Request("http://do/agents", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + agentType: subagentType, + requestContext: ctx.agent.info.request, + relatedAgentId: parentAgentId, + }), + }) + ); + + if (!spawnRes.ok) { + return "Error: Failed to spawn subagent"; + } + + const spawnData = (await spawnRes.json()) as { id: string }; + const childId = spawnData.id; + + // Get stub for the newly spawned child to invoke it + const subagent = await getAgentByName( + toolCtx.agent.exports.HubAgent, + childId + ); + + // Invoke with parent info in vars + const invokeRes = await subagent.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: String(description ?? "") }], + vars: { + ...vars, + parent: { + threadId: parentAgentId, + token, + }, + }, + }), + }) + ); + + if (!invokeRes.ok) { + return "Error: Failed to invoke subagent"; + } + + // Fire event + ctx.agent.emit(SubagentEventType.SPAWNED, { + childThreadId: childId, + agentType: subagentType, + toolCallId: toolCtx.callId, + }); + + // Record in our tables + sql`INSERT INTO mw_waiting_subagents (token, child_thread_id, tool_call_id, created_at) + VALUES (${token}, ${childId}, ${toolCtx.callId}, ${Date.now()})`; + + sql`INSERT INTO mw_subagent_links (child_thread_id, token, agent_type, status, created_at, tool_call_id) + VALUES (${childId}, ${token}, ${subagentType}, 'waiting', ${Date.now()}, ${toolCtx.callId})`; + + // Pause the parent + const runState = ctx.agent.runState; + if (runState && runState.status === "running") { + runState.status = "paused"; + runState.reason = "subagent"; + ctx.agent.emit(AgentEventType.RUN_PAUSED, { + reason: "subagent", + }); + } + + return null; // Don't add tool result yet - will come from subagent_result action + }, + }); + + ctx.registerTool(taskTool); + + // message_agent tool - send follow-up to existing subagent + const messageAgentTool = tool({ + name: "message_agent", + description: `Send a follow-up message to a subagent you previously spawned via the task tool. +Use this when you need to continue a conversation with a specific agent that already has context from prior interactions. +The agentId is returned in the result object of the task tool (e.g., {"agentId": "...", "result": "..."}).`, + inputSchema: MessageAgentParams, + execute: async ({ agentId, message }, toolCtx) => { + const sql = ctx.agent.sqlite; + + // Verify this is our child + const link = sql`SELECT status, agent_type FROM mw_subagent_links WHERE child_thread_id = ${agentId}`; + + if (!link.length) { + return "Error: Unknown agent ID. Make sure this is an agentId from a previous task result."; + } + + const token = crypto.randomUUID(); + + // Update tracking - reuse the link but new token + sql`INSERT INTO mw_waiting_subagents (token, child_thread_id, tool_call_id, created_at) + VALUES (${token}, ${agentId}, ${toolCtx.callId}, ${Date.now()})`; + + sql`UPDATE mw_subagent_links + SET status = 'waiting', token = ${token}, tool_call_id = ${toolCtx.callId} + WHERE child_thread_id = ${agentId}`; + const agent = await getAgentByName( + toolCtx.agent.exports.HubAgent, + agentId + ); + + ctx.agent.emit(SubagentEventType.MESSAGED, { + childThreadId: agentId, + message, + }); + + const res = await agent.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: message }], + vars: { + parent: { + threadId: ctx.agent.info.threadId, + token, + }, + }, + }), + }) + ); + + if (!res.ok) { + return "Error: Failed to message agent"; + } + + // Pause parent + const runState = ctx.agent.runState; + if (runState && runState.status === "running") { + runState.status = "paused"; + runState.reason = "subagent"; + ctx.agent.emit(AgentEventType.RUN_PAUSED, { reason: "subagent" }); + } + + return null; // Result comes via subagent_result action + }, + }); + + ctx.registerTool(messageAgentTool); + }, + + tags: ["subagents", "default"], +}; + +const TASK_SYSTEM_PROMPT = `## \`task\` (subagent spawner) + +You have access to a \`task\` tool to launch short-lived subagents that handle isolated tasks. These agents are ephemeral — they live only for the duration of the task and return a single result. + +When to use the task tool: +- When a task is complex and multi-step, and can be fully delegated in isolation +- When a task is independent of other tasks and can run in parallel +- When a task requires focused reasoning or heavy token/context usage that would bloat the orchestrator thread +- When sandboxing improves reliability (e.g. code execution, structured searches, data formatting) +- When you only care about the output of the subagent, and not the intermediate steps (ex. performing a lot of research and then returned a synthesized report, performing a series of computations or lookups to achieve a concise, relevant answer.) + +Subagent lifecycle: +1. **Spawn** → Provide clear role, instructions, and expected output +2. **Run** → The subagent completes the task autonomously +3. **Return** → The subagent provides a single structured result +4. **Reconcile** → Incorporate or synthesize the result into the main thread + +When NOT to use the task tool: +- If you need to see the intermediate reasoning or steps after the subagent has completed (the task tool hides them) +- If the task is trivial (a few tool calls or simple lookup) +- If delegating does not reduce token usage, complexity, or context switching +- If splitting would add latency without benefit + +## Important Task Tool Usage Notes to Remember +- Whenever possible, parallelize the work that you do. This is true for both tool calls, and for tasks. Whenever you have independent steps to complete - make tool calls, or kick off tasks (subagents) in parallel to accomplish them faster. This saves time for the user, which is incredibly important. +- Remember to use the \`task\` tool to silo independent tasks within a multi-part objective. +- You should use the \`task\` tool whenever you have a complex task that will take multiple steps, and is independent from other tasks that the agent needs to complete. These agents are highly competent and efficient.`; + +const TASK_TOOL_DESCRIPTION = `Launch an ephemeral subagent to handle complex, multi-step independent tasks with isolated context windows. + +Available agent types and the tools they have access to: +{other_agents} + +When using the Task tool, you must specify a subagentType parameter to select which agent type to use. + +## Usage notes: +1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses +2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. +3. Each task result includes an agentId that you can use with message_agent to send follow-up messages to the same agent. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. +4. The agent's outputs should generally be trusted +5. Clearly tell the agent whether you expect it to create content, perform analysis, or just do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent +6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. +7. When only the general-purpose agent is provided, you should use it for all tasks. It is great for isolating context and token usage, and completing specific, complex tasks, as it has all the same capabilities as the main agent. + +### Example usage of the general-purpose agent: + + +"general-purpose": use this agent for general purpose tasks, it has access to all tools as the main agent. + + + +User: "I want to conduct research on the accomplishments of Lebron James, Michael Jordan, and Kobe Bryant, and then compare them." +Assistant: *Uses the task tool in parallel to conduct isolated research on each of the three players* +Assistant: *Synthesizes the results of the three isolated research tasks and responds to the User* + +Research is a complex, multi-step task in it of itself. +The research of each individual player is not dependent on the research of the other players. +The assistant uses the task tool to break down the complex objective into three isolated tasks. +Each research task only needs to worry about context and tokens about one player, then returns synthesized information about each player as the Tool Result. +This means each research task can dive deep and spend tokens and context deeply researching each player, but the final result is synthesized information, and saves us tokens in the long run when comparing the players to each other. + + + + +User: "Analyze a single large code repository for security vulnerabilities and generate a report." +Assistant: *Launches a single \`task\` subagent for the repository analysis* +Assistant: *Receives report and integrates results into final summary* + +Subagent is used to isolate a large, context-heavy task, even though there is only one. This prevents the main thread from being overloaded with details. +If the user then asks followup questions, we have a concise report to reference instead of the entire history of analysis and tool calls, which is good and saves us time and money. + + + + +User: "Schedule two meetings for me and prepare agendas for each." +Assistant: *Calls the task tool in parallel to launch two \`task\` subagents (one per meeting) to prepare agendas* +Assistant: *Returns final schedules and agendas* + +Tasks are simple individually, but subagents help silo agenda preparation. +Each subagent only needs to worry about the agenda for one meeting. + + + + +User: "I want to order a pizza from Dominos, order a burger from McDonald's, and order a salad from Subway." +Assistant: *Calls tools directly in parallel to order a pizza from Dominos, a burger from McDonald's, and a salad from Subway* + +The assistant did not use the task tool because the objective is super simple and clear and only requires a few trivial tool calls. +It is better to just complete the task directly and NOT use the \`task\`tool. + + + +### Example usage with custom agents: + + +"content-reviewer": use this agent after you are done creating significant content or documents +"greeting-responder": use this agent when to respond to user greetings with a friendly joke +"research-analyst": use this agent to conduct thorough research on complex topics + + + +user: "Please write a function that checks if a number is prime" +assistant: Sure let me write a function that checks if a number is prime +assistant: First let me use the Write tool to write a function that checks if a number is prime +assistant: I'm going to use the Write tool to write the following code: + +function isPrime(n) {{ + if (n <= 1) return false + for (let i = 2; i * i <= n; i++) {{ + if (n % i === 0) return false + }} + return true +}} + + +Since significant content was created and the task was completed, now use the content-reviewer agent to review the work + +assistant: Now let me use the content-reviewer agent to review the code +assistant: Uses the Task tool to launch with the content-reviewer agent + + + +user: "Can you help me research the environmental impact of different renewable energy sources and create a comprehensive report?" + +This is a complex research task that would benefit from using the research-analyst agent to conduct thorough analysis + +assistant: I'll help you research the environmental impact of renewable energy sources. Let me use the research-analyst agent to conduct comprehensive research on this topic. +assistant: Uses the Task tool to launch with the research-analyst agent, providing detailed instructions about what research to conduct and what format the report should take + + + +user: "Hello" + +Since the user is greeting, use the greeting-responder agent to respond with a friendly joke + +assistant: "I'm going to use the Task tool to launch with the greeting-responder agent" +`; diff --git a/lib/tests/subagents.test.ts b/lib/tests/subagents.test.ts new file mode 100644 index 0000000..fa4e846 --- /dev/null +++ b/lib/tests/subagents.test.ts @@ -0,0 +1,217 @@ +import { env } from "cloudflare:test"; +import { describe, expect, it, beforeEach } from "vitest"; +import { getAgentByName } from "agents"; +import { testProvider } from "./worker"; +import type { Env } from "./worker"; + +declare module "cloudflare:test" { + interface ProvidedEnv extends Env {} +} + +/** + * Helper to wait for agent to reach a specific status. + */ +async function waitForStatus( + agentStub: { fetch: (req: Request) => Promise }, + expectedStatus: string, + timeoutMs = 5000, + pollMs = 50 +): Promise<{ state: Record; run: { status: string; reason?: string } }> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const res = await agentStub.fetch(new Request("http://do/state")); + const data = (await res.json()) as { + state: Record; + run: { status: string; reason?: string }; + }; + if (data.run.status === expectedStatus) { + return data; + } + // If status is a terminal state different from expected, return immediately + if (["error", "canceled", "completed"].includes(data.run.status) && data.run.status !== expectedStatus) { + return data; + } + await new Promise((r) => setTimeout(r, pollMs)); + } + throw new Error(`Timeout waiting for status "${expectedStatus}"`); +} + +/** + * Helper to spawn an agent of a specific type. + */ +async function spawnAgent( + agencyName: string, + agentType: string +): Promise<{ + agentId: string; + agentStub: { fetch: (req: Request) => Promise }; + agencyStub: { fetch: (req: Request | string) => Promise }; +}> { + const agencyStub = await getAgentByName(env.AGENCY, agencyName); + + const spawnRes = await agencyStub.fetch( + new Request("http://do/agents", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentType }), + }) + ); + + expect(spawnRes.ok).toBe(true); + const { id: agentId } = (await spawnRes.json()) as { id: string }; + const agentStub = await getAgentByName(env.HUB_AGENT, agentId); + + return { agentId, agentStub, agencyStub }; +} + +describe("Subagents Plugin Integration Tests", () => { + beforeEach(() => { + testProvider.reset(); + }); + + it("should spawn child agent and pause parent", async () => { + // Arrange: Parent calls task tool to spawn child + testProvider.addResponse({ + toolCalls: [ + { id: "call_1", name: "task", args: { description: "Do something", subagentType: "child-agent" } }, + ], + }); + // Child's response (child will complete while parent is paused) + testProvider.addResponse("Child completed the task!"); + // Parent's continuation after child reports back + testProvider.addResponse("Got the result from child!"); + + // Act: Spawn parent agent + const { agentStub: parentStub } = await spawnAgent("subagents-test-1", "parent-agent"); + + await parentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Spawn a child to do something" }], + }), + }) + ); + + // Wait for parent to complete (child runs immediately and reports back) + const result = await waitForStatus(parentStub, "completed", 10000); + + // Assert: Parent should have completed successfully + expect(result.run.status).toBe("completed"); + + // Check subagents state shows completed child + const subagents = result.state.subagents as Array<{ status: string; agentType: string }>; + expect(subagents).toHaveLength(1); + expect(subagents[0].status).toBe("completed"); + expect(subagents[0].agentType).toBe("child-agent"); + }); + + it("should complete parent-child-parent flow", async () => { + // Arrange: Parent calls task tool, child completes, parent gets result and finishes + testProvider.addResponse({ + toolCalls: [ + { id: "call_1", name: "task", args: { description: "Calculate 2+2", subagentType: "child-agent" } }, + ], + }); + // Child's response (will report back to parent) + testProvider.addResponse("The answer is 4"); + // Parent's final response after receiving child result + testProvider.addResponse("The child calculated: 4"); + + // Act: Spawn parent agent + const { agentStub: parentStub } = await spawnAgent("subagents-test-2", "parent-agent"); + + await parentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Ask a child to calculate 2+2" }], + }), + }) + ); + + // Wait for parent to complete (goes paused -> resumed -> completed) + const result = await waitForStatus(parentStub, "completed", 10000); + + // Assert: Parent should have completed + expect(result.run.status).toBe("completed"); + + // Check subagents state shows completed + const subagents = result.state.subagents as Array<{ status: string; report?: string }>; + expect(subagents).toHaveLength(1); + expect(subagents[0].status).toBe("completed"); + expect(subagents[0].report).toBe("The answer is 4"); + + // Check parent received tool result with child's report + const messages = result.state.messages as Array<{ role: string; content?: string }>; + const toolResult = messages.find((m) => m.role === "tool"); + expect(toolResult).toBeDefined(); + const parsed = JSON.parse(toolResult!.content!); + expect(parsed.result).toBe("The answer is 4"); + }); + + it("should support parallel subagent spawning", async () => { + // Arrange: Parent spawns two children in parallel + testProvider.addResponse({ + toolCalls: [ + { id: "call_1", name: "task", args: { description: "Research topic A", subagentType: "child-agent" } }, + { id: "call_2", name: "task", args: { description: "Research topic B", subagentType: "child-agent" } }, + ], + }); + // Both children respond + testProvider.addResponse("Research A completed"); + testProvider.addResponse("Research B completed"); + // Parent synthesizes + testProvider.addResponse("Combined research from A and B"); + + // Act + const { agentStub: parentStub } = await spawnAgent("subagents-test-3", "parent-agent"); + + await parentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "Research topics A and B in parallel" }], + }), + }) + ); + + // Wait for completion + const result = await waitForStatus(parentStub, "completed", 10000); + + // Assert + expect(result.run.status).toBe("completed"); + const subagents = result.state.subagents as Array<{ status: string }>; + expect(subagents).toHaveLength(2); + expect(subagents.every((s) => s.status === "completed")).toBe(true); + }); + + it("should expose task and message_agent tools to parent", async () => { + // Just verify the tools are registered + testProvider.addResponse("I have the task tool available"); + + const { agentStub: parentStub } = await spawnAgent("subagents-test-4", "parent-agent"); + + await parentStub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: "What tools do you have?" }], + }), + }) + ); + + await waitForStatus(parentStub, "completed"); + + // Check model request included task and message_agent tools + expect(testProvider.requests).toHaveLength(1); + const toolDefs = testProvider.requests[0].toolDefs ?? []; + const toolNames = toolDefs.map((t: { name: string }) => t.name); + expect(toolNames).toContain("task"); + expect(toolNames).toContain("message_agent"); + }); +}); diff --git a/lib/tests/worker.ts b/lib/tests/worker.ts index 1748b8e..132df1f 100644 --- a/lib/tests/worker.ts +++ b/lib/tests/worker.ts @@ -56,6 +56,24 @@ const testBlueprint: AgentBlueprint = { model: "gpt-4o-mini", }; +// Parent agent that can spawn subagents +const parentBlueprint: AgentBlueprint = { + name: "parent-agent", + description: "A parent agent that can spawn subagents", + prompt: "You are a parent agent. Use the task tool to delegate work to child agents.", + capabilities: ["@test", "@subagents"], + model: "gpt-4o-mini", +}; + +// Child agent that reports back to parent +const childBlueprint: AgentBlueprint = { + name: "child-agent", + description: "A child agent that reports back to parent", + prompt: "You are a child agent. Complete the task and report back.", + capabilities: ["@test", "@subagent_reporter"], + model: "gpt-4o-mini", +}; + const hub = new AgentHub({ defaultModel: "gpt-4o-mini", provider: testProvider }) .addTool(echoTool, ["@test"]) .addTool(addTool, ["@test"]) @@ -63,8 +81,12 @@ const hub = new AgentHub({ defaultModel: "gpt-4o-mini", provider: testProvider } .use(plugins.planning) .use(plugins.hitl) .use(plugins.context) + .use(plugins.subagents) + .use(plugins.subagentReporter) .use(plugins.logger, ["@test"]) - .addAgent(testBlueprint); + .addAgent(testBlueprint) + .addAgent(parentBlueprint) + .addAgent(childBlueprint); // Export with standard names so ctx.exports.Agency and ctx.exports.HubAgent work export const { HubAgent, Agency, handler } = hub.export(); From 3029e8f32e980c0823078347af54ee28d9304482 Mon Sep 17 00:00:00 2001 From: Steve James Date: Thu, 15 Jan 2026 12:40:21 +0100 Subject: [PATCH 4/8] clean up personal-hub ui, add ide-style layout with tabs and command palette --- examples/personal-hub/ui/App.tsx | 646 ++++++--------- .../personal-hub/ui/components/AgentPanel.tsx | 137 ++++ .../ui/components/CommandPalette.tsx | 294 +++++++ .../personal-hub/ui/components/HomeView.tsx | 765 ------------------ .../personal-hub/ui/components/MindPanel.tsx | 253 ------ .../personal-hub/ui/components/Sidebar.tsx | 360 --------- .../personal-hub/ui/components/TabBar.tsx | 109 +++ .../personal-hub/ui/components/TopHeader.tsx | 130 +++ examples/personal-hub/ui/components/index.ts | 9 +- .../ui/components/shared/types.ts | 22 - .../ui/components/shared/utils.ts | 12 - examples/personal-hub/ui/hooks/index.ts | 4 +- .../personal-hub/ui/hooks/useAgentSystem.ts | 381 --------- 13 files changed, 940 insertions(+), 2182 deletions(-) create mode 100644 examples/personal-hub/ui/components/AgentPanel.tsx create mode 100644 examples/personal-hub/ui/components/CommandPalette.tsx delete mode 100644 examples/personal-hub/ui/components/HomeView.tsx delete mode 100644 examples/personal-hub/ui/components/MindPanel.tsx delete mode 100644 examples/personal-hub/ui/components/Sidebar.tsx create mode 100644 examples/personal-hub/ui/components/TabBar.tsx create mode 100644 examples/personal-hub/ui/components/TopHeader.tsx diff --git a/examples/personal-hub/ui/App.tsx b/examples/personal-hub/ui/App.tsx index 1bc6c0f..d26ca9f 100644 --- a/examples/personal-hub/ui/App.tsx +++ b/examples/personal-hub/ui/App.tsx @@ -1,7 +1,6 @@ import { useState, useMemo, useEffect, useCallback, StrictMode } from "react"; import { useLocation, useRoute } from "wouter"; import { - Sidebar, ContentHeader, ChatView, TraceView, @@ -9,34 +8,30 @@ import { TodosView, SettingsView, ConfirmModal, - MindPanel, - HomeView, ErrorBoundary, ToastProvider, useToast, + TopHeader, + TabBar, + CommandPalette, + AgentPanel, type TabId, type Message, type Todo, + type OpenTab, } from "./components"; import { useAgencies, useAgency, useAgent, usePlugins, - useActivityFeed, - useAgencyMetrics, setStoredSecret, QueryClient, QueryClientProvider, } from "./hooks"; -import type { AgentBlueprint, ChatMessage, ToolCall as APIToolCall } from "agents-hub/client"; +import type { AgentBlueprint, ChatMessage, ToolCall as APIToolCall, AgentSummary } from "agents-hub/client"; import { createRoot } from "react-dom/client"; -import { - type ScheduleSummary, - type DashboardMetrics, - convertChatMessages, - isSystemBlueprint, -} from "./components/shared"; +import { convertChatMessages } from "./components/shared"; const queryClient = new QueryClient({ defaultOptions: { @@ -61,8 +56,7 @@ function BlueprintPicker({ onSelect: (bp: AgentBlueprint) => void; onClose: () => void; }) { - // Filter out system blueprints - const visibleBlueprints = blueprints.filter((bp) => !isSystemBlueprint(bp)); + // All blueprints are visible (no filtering) return (
@@ -79,13 +73,13 @@ function BlueprintPicker({
- {visibleBlueprints.length === 0 ? ( + {blueprints.length === 0 ? (

// NO BLUEPRINTS AVAILABLE

) : (
- {visibleBlueprints.map((bp) => ( + {blueprints.map((bp) => ( - )} -

+
+

AGENCY_CONFIG

-

+

ID: {agency?.name || "UNKNOWN"}

@@ -760,7 +739,6 @@ function SettingsRoute({ void; -}) { - const { agencies } = useAgencies(); - const { agents, blueprints, spawnAgent, getOrCreateMind, sendMessageToAgent } = useAgency(agencyId); - const { items: activityItems, addUserMessage, removeUserMessage, subscribeToAgent } = useActivityFeed(agencyId); - const { metrics } = useAgencyMetrics(agencyId); - const [, navigate] = useLocation(); - const { showError } = useToast(); - - const agency = agencies.find((a) => a.id === agencyId); - - // Build dashboard metrics - const dashboardMetrics: DashboardMetrics = useMemo(() => { - const activeAgents = agents.filter((a) => !a.agentType.startsWith("_")).length; - return { - agents: { - total: activeAgents, - active: metrics.runsCompleted > 0 ? Math.min(activeAgents, metrics.runsCompleted) : 0, - idle: activeAgents, - error: metrics.runsErrored, - }, - runs: { - today: metrics.runsCompleted + metrics.runsErrored, - week: metrics.runsCompleted + metrics.runsErrored, - successRate: - metrics.runsCompleted + metrics.runsErrored > 0 - ? Math.round((metrics.runsCompleted / (metrics.runsCompleted + metrics.runsErrored)) * 100) - : 100, - hourlyData: Array.from(metrics.tokensByDay.values()).slice(-12), - }, - schedules: { - total: 0, // Will be populated from useAgency - active: 0, - paused: 0, - }, - tokens: metrics.totalTokens > 0 ? { - today: metrics.totalTokens, - week: metrics.totalTokens, - dailyData: Array.from(metrics.tokensByDay.values()), - } : undefined, - responseTime: metrics.responseTimes.length > 0 ? { - avg: Math.round(metrics.responseTimes.reduce((a, b) => a + b, 0) / metrics.responseTimes.length), - p95: Math.round(metrics.responseTimes.sort((a, b) => a - b)[Math.floor(metrics.responseTimes.length * 0.95)] || 0), - recentData: metrics.responseTimes.slice(-12), - } : undefined, - }; - }, [agents, metrics]); - - // Handle sending message to agent - stays in command center view - const handleSendMessage = useCallback( - async (target: string, message: string) => { - let optimisticMessageId: string | undefined; - - try { - // Find or create the target agent - let targetAgentId: string; - let agentType = target; - - if (target === "_agency-mind") { - // Get or create the agency mind - targetAgentId = await getOrCreateMind(); - agentType = "_agency-mind"; - } else { - // Find existing agent - const agent = agents.find((a) => a.id === target); - if (!agent) { - console.error("[HomeRoute] Agent not found:", target); - return; - } - targetAgentId = agent.id; - agentType = agent.agentType; - } - - // Register agent type for activity feed display - subscribeToAgent(targetAgentId, agentType); - - // Add optimistic update to activity feed (returns ID for rollback) - optimisticMessageId = addUserMessage(target, message, targetAgentId); - - // Actually send the message to the agent - // Response will come via agency WebSocket - no polling needed - await sendMessageToAgent(targetAgentId, message); - } catch (err) { - // Rollback optimistic update on failure - if (optimisticMessageId) { - removeUserMessage(optimisticMessageId); - } - console.error("[HomeRoute] Failed to send message:", err); - showError("Failed to send message. Please try again."); - } - }, - [agents, getOrCreateMind, addUserMessage, removeUserMessage, sendMessageToAgent, subscribeToAgent, showError] - ); - - // Handle creating new agent from blueprint - // If message is provided, send it to the agent and stay in command center - const handleCreateAgent = useCallback( - async (blueprintName: string, message?: string) => { - let optimisticMessageId: string | undefined; - - try { - const agent = await spawnAgent(blueprintName); - - if (message) { - // Register agent type for activity feed display - subscribeToAgent(agent.id, blueprintName); - - // Send message and stay in command center (returns ID for rollback) - optimisticMessageId = addUserMessage(blueprintName, message, agent.id); - - // Send the message - response will come via agency WebSocket - await sendMessageToAgent(agent.id, message); - } else { - // Navigate to agent page when no initial message - navigate(`/${agencyId}/agent/${agent.id}`); - } - } catch (err) { - // Rollback optimistic update on failure - if (optimisticMessageId) { - removeUserMessage(optimisticMessageId); - } - console.error("[HomeRoute] Failed to create agent:", err); - showError("Failed to create agent. Please try again."); - } - }, - [agencyId, spawnAgent, navigate, addUserMessage, removeUserMessage, sendMessageToAgent, subscribeToAgent, showError] - ); - - return ( - - ); -} - -// ============================================================================ -// Main Content Router -// ============================================================================ - -function MainContent({ - agencyId, - onMenuClick, -}: { - agencyId: string; - onMenuClick: () => void; -}) { - // Match routes - const [matchAgent, paramsAgent] = useRoute("/:agencyId/agent/:agentId"); - const [matchAgentTab, paramsAgentTab] = useRoute("/:agencyId/agent/:agentId/:tab"); - const [matchSettings] = useRoute("/:agencyId/settings"); - const [matchHome] = useRoute("/:agencyId"); - - if (matchSettings) { - return ; - } - - if (matchAgentTab && paramsAgentTab) { - return ( - - ); - } - - if (matchAgent && paramsAgent) { - return ( - - ); - } - - // Default to home/dashboard view - return ( - - ); -} - -// ============================================================================ -// App Component +// App Component - IDE-style layout with tabs // ============================================================================ export default function App() { @@ -1034,28 +809,20 @@ export default function App() { const [isLocked, setIsLocked] = useState(false); const [authError, setAuthError] = useState(); - // Mobile menu state - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(() => { - if (typeof window !== "undefined") { - return window.innerWidth >= 768; - } - return true; - }); - - // Modal state - const [showBlueprintPicker, setShowBlueprintPicker] = useState(false); + // UI state + const [isPanelOpen, setIsPanelOpen] = useState(true); + const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [showAgencyModal, setShowAgencyModal] = useState(false); const [newAgencyName, setNewAgencyName] = useState(""); - // Agency Mind panel state - const [isMindOpen, setIsMindOpen] = useState(false); - const [mindAgentId, setMindAgentId] = useState(null); - const [isMindLoading, setIsMindLoading] = useState(false); + // Tab state - tracks open agents + const [openTabs, setOpenTabs] = useState([]); - // Parse agencyId from URL + // Parse agencyId and agentId from URL const pathParts = location.split("/").filter(Boolean); const agencyId = pathParts[0] || null; const agentId = pathParts[1] === "agent" ? pathParts[2] || null : null; + const isOnSettings = location.endsWith("/settings"); // Data hooks const { @@ -1064,21 +831,63 @@ export default function App() { error: agenciesError, hasFetched: agenciesFetched, } = useAgencies(); - const { agents, blueprints, schedules, spawnAgent, getOrCreateMind } = useAgency(agencyId); + const { agents, blueprints, spawnAgent } = useAgency(agencyId); const { run: runState } = useAgent(agencyId, agentId); - // Agency Mind agent state - const { - state: mindState, - run: mindRunState, - connected: mindConnected, - loading: mindAgentLoading, - sendMessage: sendMindMessage, - cancel: cancelMind, - } = useAgent(agencyId, mindAgentId); - const isUnauthorized = agenciesError?.message.includes("401") ?? false; + // Current agency + const currentAgency = agencies.find((a) => a.id === agencyId); + + // Track running agents + const runningAgentIds = useMemo(() => { + const ids = new Set(); + if (agentId && runState?.status === "running") { + ids.add(agentId); + } + return ids; + }, [agentId, runState]); + + // Sync tabs with agents - add tab when navigating to agent + useEffect(() => { + if (!agentId || !agencyId) return; + + const agent = agents.find((a) => a.id === agentId); + if (!agent) return; + + // Check if tab already exists + const existingTab = openTabs.find((t) => t.agentId === agentId); + if (!existingTab) { + // Add new tab + setOpenTabs((prev) => [ + ...prev, + { + id: `tab-${agentId}`, + agentId: agent.id, + agentType: agent.agentType, + isRunning: runState?.status === "running", + }, + ]); + } + }, [agentId, agencyId, agents, runState?.status]); + + // Update tab running state + useEffect(() => { + if (!agentId) return; + setOpenTabs((prev) => + prev.map((tab) => + tab.agentId === agentId + ? { ...tab, isRunning: runState?.status === "running" } + : tab + ) + ); + }, [agentId, runState?.status]); + + // Clear tabs when agency changes + useEffect(() => { + setOpenTabs([]); + }, [agencyId]); + // Check if we got a 401 error useEffect(() => { if (agenciesError && agenciesError.message.includes("401")) { @@ -1093,66 +902,80 @@ export default function App() { window.location.reload(); }, []); - // Reset mind agent when agency changes + // Auto-select agency if only one exists useEffect(() => { - setMindAgentId(null); - setIsMindOpen(false); - }, [agencyId]); + if (!agenciesFetched || isLocked || isUnauthorized) return; + if (!agencyId && agencies.length === 1) { + navigate(`/${agencies[0].id}`); + } + }, [agenciesFetched, isLocked, isUnauthorized, agencyId, agencies, navigate]); - // Handle opening the Agency Mind panel - const handleOpenMind = useCallback(async () => { - if (!agencyId) return; + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ctrl+K or Cmd+K - Open command palette + if ((e.ctrlKey || e.metaKey) && e.key === "k") { + e.preventDefault(); + setIsCommandPaletteOpen(true); + return; + } - if (mindAgentId) { - setIsMindOpen(true); - return; - } + // Ctrl+B - Toggle panel + if ((e.ctrlKey || e.metaKey) && e.key === "b") { + e.preventDefault(); + setIsPanelOpen((prev) => !prev); + return; + } - setIsMindLoading(true); - try { - const id = await getOrCreateMind(); - setMindAgentId(id); - setIsMindOpen(true); - } catch (err) { - console.error("Failed to get or create mind:", err); - showError("Failed to open Agency Mind. Please try again."); - } finally { - setIsMindLoading(false); - } - }, [agencyId, mindAgentId, getOrCreateMind, showError]); - - // Derive agent status - const agentStatus = useMemo(() => { - const status: Record = {}; - agents.forEach((a) => { - if (a.id === agentId && runState) { - status[a.id] = - runState.status === "running" - ? "running" - : runState.status === "completed" - ? "done" - : runState.status === "error" - ? "error" - : "idle"; - } else { - status[a.id] = "idle"; + // Ctrl+W - Close current tab + if ((e.ctrlKey || e.metaKey) && e.key === "w" && agentId) { + e.preventDefault(); + handleCloseTab(`tab-${agentId}`); + return; + } + + // Ctrl+1-9 - Switch to tab by index + if ((e.ctrlKey || e.metaKey) && e.key >= "1" && e.key <= "9") { + e.preventDefault(); + const index = parseInt(e.key) - 1; + if (openTabs[index]) { + navigate(`/${agencyId}/agent/${openTabs[index].agentId}`); + } + return; } - }); - return status; - }, [agents, agentId, runState]); - - // Convert schedules to summary format - const scheduleSummaries: ScheduleSummary[] = useMemo(() => { - return schedules.map((s) => ({ - id: s.id, - name: s.name, - agentType: s.agentType, - status: (s.status === "paused" ? "paused" : "active") as "active" | "paused", - type: s.type as "once" | "cron" | "interval", - })); - }, [schedules]); + + // Ctrl+Tab - Next tab + if (e.ctrlKey && e.key === "Tab" && !e.shiftKey) { + e.preventDefault(); + const currentIndex = openTabs.findIndex((t) => t.agentId === agentId); + const nextIndex = (currentIndex + 1) % openTabs.length; + if (openTabs[nextIndex]) { + navigate(`/${agencyId}/agent/${openTabs[nextIndex].agentId}`); + } + return; + } + + // Ctrl+Shift+Tab - Previous tab + if (e.ctrlKey && e.key === "Tab" && e.shiftKey) { + e.preventDefault(); + const currentIndex = openTabs.findIndex((t) => t.agentId === agentId); + const prevIndex = currentIndex <= 0 ? openTabs.length - 1 : currentIndex - 1; + if (openTabs[prevIndex]) { + navigate(`/${agencyId}/agent/${openTabs[prevIndex].agentId}`); + } + return; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [agencyId, agentId, openTabs, navigate]); // Handlers + const handleSelectAgency = (id: string) => { + navigate(`/${id}`); + }; + const handleCreateAgency = async (name?: string) => { if (name) { try { @@ -1169,41 +992,65 @@ export default function App() { } }; - const handleCreateAgent = async (agentType?: string) => { - if (agentType && agencyId) { - try { - const agent = await spawnAgent(agentType); - navigate(`/${agencyId}/agent/${agent.id}`); - setShowBlueprintPicker(false); - } catch (err) { - console.error("[App] Failed to create agent:", err); - showError("Failed to create agent. Please try again."); - } - } else { - setShowBlueprintPicker(true); + const handleSelectAgent = (agent: { id: string; agentType: string }) => { + if (agencyId) { + navigate(`/${agencyId}/agent/${agent.id}`); } }; - // Auto-select agency if only one exists, or navigate away from invalid agency - useEffect(() => { - if (!agenciesFetched || isLocked || isUnauthorized) return; + const handleCreateFromBlueprint = async (blueprint: AgentBlueprint) => { + if (!agencyId) return; + try { + const agent = await spawnAgent(blueprint.name); + navigate(`/${agencyId}/agent/${agent.id}`); + } catch (err) { + console.error("[App] Failed to create agent:", err); + showError("Failed to create agent. Please try again."); + } + }; - // If no agency selected and exactly one exists, auto-select it - if (!agencyId && agencies.length === 1) { - navigate(`/${agencies[0].id}`); + const handleSelectTab = (tabId: string) => { + const tab = openTabs.find((t) => t.id === tabId); + if (tab && agencyId) { + navigate(`/${agencyId}/agent/${tab.agentId}`); } - }, [agenciesFetched, isLocked, isUnauthorized, agencyId, agencies, navigate]); + }; + + const handleCloseTab = (tabId: string) => { + const tabIndex = openTabs.findIndex((t) => t.id === tabId); + const tab = openTabs[tabIndex]; + + setOpenTabs((prev) => prev.filter((t) => t.id !== tabId)); + + // Navigate to adjacent tab or home + if (tab && tab.agentId === agentId) { + const remainingTabs = openTabs.filter((t) => t.id !== tabId); + if (remainingTabs.length > 0) { + const nextTab = remainingTabs[Math.min(tabIndex, remainingTabs.length - 1)]; + navigate(`/${agencyId}/agent/${nextTab.agentId}`); + } else { + navigate(`/${agencyId}`); + } + } + }; + const handleOpenSettings = () => { + if (agencyId) { + navigate(`/${agencyId}/settings`); + } + }; + + // Loading state if (!agenciesFetched) { return ; } + // Auth required if (isLocked || isUnauthorized) { return ; } - // No agency selected - show agency select/create modal - // Skip if exactly one agency (will auto-navigate via useEffect) + // No agency selected if (!agencyId && agencies.length !== 1) { return ( - {/* Sidebar */} - + {/* Top Header */} + handleCreateAgent()} - schedules={scheduleSummaries} - agentStatus={agentStatus} - isOpen={isMobileMenuOpen} - onClose={() => setIsMobileMenuOpen(false)} - onOpenMind={handleOpenMind} - isMindActive={isMindOpen} + selectedAgencyName={currentAgency?.name} + onSelectAgency={handleSelectAgency} + onCreateAgency={() => handleCreateAgency()} + onOpenSettings={handleOpenSettings} + onOpenCommandPalette={() => setIsCommandPaletteOpen(true)} + onTogglePanel={() => setIsPanelOpen((prev) => !prev)} + isPanelOpen={isPanelOpen} /> - {/* Blueprint picker modal */} - {showBlueprintPicker && ( - handleCreateAgent(bp.name)} - onClose={() => setShowBlueprintPicker(false)} + {/* Tab Bar */} + {agencyId && !isOnSettings && ( + setIsCommandPaletteOpen(true)} /> )} + {/* Main content area */} +
+ {/* Agent Panel (sidebar) */} + {agencyId && ( + + )} + + {/* Content */} +
+ {isOnSettings && agencyId ? ( + + ) : agentId && agencyId ? ( + + ) : agencyId ? ( + setIsCommandPaletteOpen(true)} /> + ) : null} +
+
+ + {/* Command Palette */} + setIsCommandPaletteOpen(false)} + agents={agents} + blueprints={blueprints} + onSelectAgent={handleSelectAgent} + onCreateFromBlueprint={handleCreateFromBlueprint} + /> + {/* Agency creation modal */} {showAgencyModal && ( )} +

+ ); +} - {/* Main content */} -
- {agencyId && ( - setIsMobileMenuOpen(true)} - /> - )} +// Empty state when no agent is selected +function EmptyState({ onOpenCommandPalette }: { onOpenCommandPalette: () => void }) { + return ( +
+
+
_
+

+ NO AGENT SELECTED +

+

+ Open an agent or create a new one to get started. +

+
- - {/* Agency Mind Panel */} - {agencyId && ( - setIsMindOpen(false)} - agencyId={agencyId} - agencyName={agencies.find((a) => a.id === agencyId)?.name} - mindState={mindState} - runState={mindRunState} - connected={mindConnected} - loading={isMindLoading || mindAgentLoading} - onSendMessage={sendMindMessage} - onStop={cancelMind} - /> - )}
); } diff --git a/examples/personal-hub/ui/components/AgentPanel.tsx b/examples/personal-hub/ui/components/AgentPanel.tsx new file mode 100644 index 0000000..3e65ab5 --- /dev/null +++ b/examples/personal-hub/ui/components/AgentPanel.tsx @@ -0,0 +1,137 @@ +/** + * AgentPanel - Toggleable sidebar showing agents and blueprints + */ +import { useState } from "react"; +import { cn } from "../lib/utils"; +import type { AgentBlueprint, AgentSummary } from "./shared"; +import { shortId, formatRelativeTime } from "./shared"; + +interface AgentPanelProps { + isOpen: boolean; + agents: AgentSummary[]; + blueprints: AgentBlueprint[]; + runningAgentIds?: Set; + onSelectAgent: (agent: AgentSummary) => void; + onCreateFromBlueprint: (blueprint: AgentBlueprint) => void; +} + +export function AgentPanel({ + isOpen, + agents, + blueprints, + runningAgentIds = new Set(), + onSelectAgent, + onCreateFromBlueprint, +}: AgentPanelProps) { + const [agentsExpanded, setAgentsExpanded] = useState(true); + const [blueprintsExpanded, setBlueprintsExpanded] = useState(true); + + if (!isOpen) return null; + + return ( +
+ {/* Agents section */} +
+ + + {agentsExpanded && ( +
+ {agents.length === 0 ? ( +

+ No agents +

+ ) : ( +
+ {agents.map((agent) => { + const isRunning = runningAgentIds.has(agent.id); + return ( + + ); + })} +
+ )} +
+ )} +
+ + {/* Blueprints section */} +
+ + + {blueprintsExpanded && ( +
+ {blueprints.length === 0 ? ( +

+ No blueprints +

+ ) : ( +
+ {blueprints.map((blueprint) => ( + + ))} +
+ )} +
+ )} +
+ + {/* Footer hint */} +
+ Ctrl+B to toggle +
+
+ ); +} diff --git a/examples/personal-hub/ui/components/CommandPalette.tsx b/examples/personal-hub/ui/components/CommandPalette.tsx new file mode 100644 index 0000000..0a8cfe2 --- /dev/null +++ b/examples/personal-hub/ui/components/CommandPalette.tsx @@ -0,0 +1,294 @@ +/** + * CommandPalette - Fuzzy search for agents and blueprints + */ +import { useState, useEffect, useRef, useMemo } from "react"; +import { cn } from "../lib/utils"; +import type { AgentBlueprint, AgentSummary } from "./shared"; + +interface CommandPaletteProps { + isOpen: boolean; + onClose: () => void; + agents: AgentSummary[]; + blueprints: AgentBlueprint[]; + onSelectAgent: (agent: AgentSummary) => void; + onCreateFromBlueprint: (blueprint: AgentBlueprint) => void; +} + +type CommandItem = + | { type: "agent"; agent: AgentSummary } + | { type: "blueprint"; blueprint: AgentBlueprint }; + +export function CommandPalette({ + isOpen, + onClose, + agents, + blueprints, + onSelectAgent, + onCreateFromBlueprint, +}: CommandPaletteProps) { + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Build searchable items + const items: CommandItem[] = useMemo(() => { + const result: CommandItem[] = []; + + // Agents first + agents.forEach((agent) => { + result.push({ type: "agent", agent }); + }); + + // Then blueprints + blueprints.forEach((blueprint) => { + result.push({ type: "blueprint", blueprint }); + }); + + return result; + }, [agents, blueprints]); + + // Filter items based on query + const filteredItems = useMemo(() => { + if (!query.trim()) return items; + + const lowerQuery = query.toLowerCase(); + return items.filter((item) => { + if (item.type === "agent") { + return ( + item.agent.agentType.toLowerCase().includes(lowerQuery) || + item.agent.id.toLowerCase().includes(lowerQuery) + ); + } else { + return ( + item.blueprint.name.toLowerCase().includes(lowerQuery) || + item.blueprint.description?.toLowerCase().includes(lowerQuery) + ); + } + }); + }, [items, query]); + + // Reset selection when filtered items change + useEffect(() => { + setSelectedIndex(0); + }, [filteredItems.length]); + + // Focus input when opened + useEffect(() => { + if (isOpen) { + setQuery(""); + setSelectedIndex(0); + setTimeout(() => inputRef.current?.focus(), 0); + } + }, [isOpen]); + + // Scroll selected item into view + useEffect(() => { + if (listRef.current) { + const selected = listRef.current.querySelector("[data-selected=true]"); + selected?.scrollIntoView({ block: "nearest" }); + } + }, [selectedIndex]); + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, filteredItems.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 0)); + break; + case "Enter": + e.preventDefault(); + const selected = filteredItems[selectedIndex]; + if (selected) { + if (selected.type === "agent") { + onSelectAgent(selected.agent); + } else { + onCreateFromBlueprint(selected.blueprint); + } + onClose(); + } + break; + case "Escape": + e.preventDefault(); + onClose(); + break; + } + }; + + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Palette */} +
+ {/* Search input */} +
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search agents and blueprints..." + className="w-full px-4 py-3 bg-transparent text-white text-sm placeholder:text-white/30 focus:outline-none" + /> +
+ + {/* Results */} +
+ {filteredItems.length === 0 ? ( +
+

+ No results found +

+
+ ) : ( + <> + {/* Section: Agents */} + {filteredItems.some((i) => i.type === "agent") && ( +
+ Agents +
+ )} + {filteredItems + .filter((i) => i.type === "agent") + .map((item, idx) => { + const realIndex = filteredItems.indexOf(item); + const agent = (item as { type: "agent"; agent: AgentSummary }).agent; + return ( + + ); + })} + + {/* Section: Blueprints */} + {filteredItems.some((i) => i.type === "blueprint") && ( +
+ Blueprints (spawn new) +
+ )} + {filteredItems + .filter((i) => i.type === "blueprint") + .map((item) => { + const realIndex = filteredItems.indexOf(item); + const blueprint = (item as { type: "blueprint"; blueprint: AgentBlueprint }).blueprint; + return ( + + ); + })} + + )} +
+ + {/* Footer with shortcuts */} +
+ ↑↓ Navigate + ↵ Select + Esc Close +
+
+
+ ); +} diff --git a/examples/personal-hub/ui/components/HomeView.tsx b/examples/personal-hub/ui/components/HomeView.tsx deleted file mode 100644 index 00968a8..0000000 --- a/examples/personal-hub/ui/components/HomeView.tsx +++ /dev/null @@ -1,765 +0,0 @@ -/** - * HomeView - Dashboard home view with metrics, activity feed, and command input - * - * This is the landing page when an agency is selected but no agent. - * Combines the best of Command Center into the classic layout. - */ -import { useState, useRef, useEffect, useCallback, useMemo } from "react"; -import { useLocation } from "wouter"; -import { cn } from "../lib/utils"; -import { - type DashboardMetrics, - type ActivityItem, - type MentionTarget, - type AgentBlueprint, - type AgentSummary, - isSystemBlueprint, - isSystemAgent, - formatNumber, -} from "./shared"; - -// ============================================================================ -// Dashboard Component (adapted from command-center/Dashboard.tsx) -// ============================================================================ - -function AsciiBar({ value, max, width = 10 }: { value: number; max: number; width?: number }) { - const filled = Math.round((value / Math.max(max, 1)) * width); - const empty = width - filled; - return ( - - {"█".repeat(Math.max(0, filled))} - {"░".repeat(Math.max(0, empty))} - - ); -} - -function AsciiSparkline({ data }: { data: number[] }) { - if (data.length === 0) return ; - - const blocks = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; - const max = Math.max(...data, 1); - - return ( - - {data.slice(-12).map((value, i) => { - const level = Math.floor((value / max) * 7); - return {blocks[Math.min(level, 7)]}; - })} - - ); -} - -function Percentage({ value }: { value: number }) { - const color = - value >= 90 - ? "text-emerald-400/70" - : value >= 70 - ? "text-amber-400/70" - : "text-red-400/70"; - return {value}%; -} - -function Duration({ ms }: { ms: number }) { - if (ms < 1000) return {ms}ms; - return {(ms / 1000).toFixed(1)}s; -} - -function StatBox({ - label, - value, - subValue, - children, - compact = false, -}: { - label: string; - value: React.ReactNode; - subValue?: string; - children?: React.ReactNode; - compact?: boolean; -}) { - return ( -
-
- {label} -
-
- {value} -
- {subValue &&
{subValue}
} - {children &&
{children}
} -
- ); -} - -function Dashboard({ metrics }: { metrics: DashboardMetrics }) { - const { agents, runs, schedules, tokens, responseTime, memory } = metrics; - - const agentBreakdown = - agents.total > 0 ? `${agents.active}↑ ${agents.idle}○ ${agents.error}✕` : "—"; - - return ( -
-
- DASHBOARD -
- -
- - - active - - - - - - - — - ) : ( - - ) - } - compact - > - {runs.today === 0 && runs.week === 0 ? ( - NO RUNS - ) : ( - - )} - - - - - - {schedules.active} - | - - {schedules.paused} - - - - {responseTime && ( - } - subValue={`p95: ${responseTime.p95}ms`} - compact - > - - - )} - - {tokens && ( - - - - )} - - {memory && ( - - )} -
-
- ); -} - -// ============================================================================ -// Activity Feed Component (adapted from command-center/ActivityFeed.tsx) -// ============================================================================ - -function formatActivityTime(timestamp: string): string { - const date = new Date(timestamp); - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - - const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - - if (isToday) return time; - return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " + time; -} - -function getSourceName(item: ActivityItem): string { - if (item.from === "you") return "YOU"; - if (item.type === "system") return "SYSTEM"; - - const agentType = item.agentType || ""; - if (agentType === "_agency-mind") return "AGENCY MIND"; - if (agentType === "_hub-mind") return "HUB MIND"; - if (agentType.startsWith("_")) - return agentType.slice(1).toUpperCase().replace(/-/g, " "); - - return agentType.toUpperCase(); -} - -function getTargetName(target: string): string { - if (target === "_agency-mind") return "AGENCY MIND"; - if (target === "_hub-mind") return "HUB MIND"; - if (target.startsWith("_")) return target.slice(1).toUpperCase().replace(/-/g, " "); - return target.toUpperCase(); -} - -function isMind(item: ActivityItem): boolean { - const agentType = item.agentType || ""; - return agentType === "_agency-mind" || agentType === "_hub-mind"; -} - -function SystemMessage({ item }: { item: ActivityItem }) { - return ( -
- - // {item.content || item.event} - -
- ); -} - -function UserMessage({ - item, - onClick, -}: { - item: ActivityItem; - onClick?: () => void; -}) { - const target = item.to ? getTargetName(item.to) : ""; - - return ( -
-
- {target && ( - {target} {'<-'} - )} - - YOU - -
- - - {item.content && ( -
-

{item.content}

-
- )} - -
- - {formatActivityTime(item.timestamp)} - -
-
- ); -} - -function AgentMessage({ - item, - onClick, -}: { - item: ActivityItem; - onClick?: () => void; -}) { - const sourceName = getSourceName(item); - const isRunning = item.status === "running"; - const isMindAgent = isMind(item); - const agentId = (item.agentId || "").slice(0, 6); - - return ( -
-
- - {sourceName} - - {agentId && ( - {agentId} - )} - {isRunning && ( - - running - - )} -
- - {item.content && ( -
-

{item.content}

-
- )} - - {item.type === "agent_event" && item.event && !item.content && ( -
- {item.event} - {item.details && : {item.details}} -
- )} - -
- - {formatActivityTime(item.timestamp)} - -
-
- ); -} - -function ActivityFeed({ - items, - onItemClick, -}: { - items: ActivityItem[]; - onItemClick?: (item: ActivityItem) => void; -}) { - const containerRef = useRef(null); - const shouldAutoScroll = useRef(true); - - useEffect(() => { - if (shouldAutoScroll.current && containerRef.current) { - containerRef.current.scrollTop = containerRef.current.scrollHeight; - } - }, [items]); - - const handleScroll = () => { - if (!containerRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = containerRef.current; - shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 50; - }; - - return ( -
- {items.length === 0 ? ( -
-
-
_
-

- NO ACTIVITY YET -

-

- Type a message below. Use @ to mention agents. -

-
-
- ) : ( -
- {items.map((item) => { - if (item.type === "system") { - return ; - } - if (item.from === "you") { - return ( - onItemClick?.(item) : undefined} - /> - ); - } - return ( - onItemClick?.(item) : undefined} - /> - ); - })} -
- )} -
- ); -} - -// ============================================================================ -// Command Input Component (adapted from command-center/CommandInput.tsx) -// ============================================================================ - -function CommandInput({ - targets, - defaultTarget, - onSubmit, - disabled = false, - placeholder = "Type a message... (@ to mention)", -}: { - targets: MentionTarget[]; - defaultTarget: string; - onSubmit: (target: string, message: string) => Promise; - disabled?: boolean; - placeholder?: string; -}) { - const [input, setInput] = useState(""); - const [selectedTarget, setSelectedTarget] = useState(defaultTarget); - const [showMentions, setShowMentions] = useState(false); - const [mentionFilter, setMentionFilter] = useState(""); - const [selectedMentionIndex, setSelectedMentionIndex] = useState(0); - const [isSending, setIsSending] = useState(false); - const inputRef = useRef(null); - const mentionListRef = useRef(null); - - // Update selected target when default changes - useEffect(() => { - setSelectedTarget(defaultTarget); - }, [defaultTarget]); - - const filteredTargets = useMemo(() => { - if (!mentionFilter) return targets; - const lower = mentionFilter.toLowerCase(); - return targets.filter((t) => t.label.toLowerCase().includes(lower)); - }, [targets, mentionFilter]); - - // Reset selection when filter changes - useEffect(() => { - setSelectedMentionIndex(0); - }, [filteredTargets.length]); - - // Scroll selected item into view - useEffect(() => { - if (showMentions && mentionListRef.current) { - const selected = mentionListRef.current.querySelector("[data-selected=true]"); - selected?.scrollIntoView({ block: "nearest" }); - } - }, [selectedMentionIndex, showMentions]); - - const handleInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setInput(value); - - // Check for @ mention - const lastAtIndex = value.lastIndexOf("@"); - if (lastAtIndex >= 0 && lastAtIndex === value.length - 1) { - setShowMentions(true); - setMentionFilter(""); - } else if (lastAtIndex >= 0 && showMentions) { - const afterAt = value.slice(lastAtIndex + 1); - if (!afterAt.includes(" ")) { - setMentionFilter(afterAt); - } else { - setShowMentions(false); - } - } else if (!value.includes("@")) { - setShowMentions(false); - } - }; - - const selectMention = useCallback( - (target: MentionTarget) => { - const lastAtIndex = input.lastIndexOf("@"); - const beforeAt = lastAtIndex >= 0 ? input.slice(0, lastAtIndex) : input; - setInput(beforeAt); - setSelectedTarget(target.id); - setShowMentions(false); - setMentionFilter(""); - inputRef.current?.focus(); - }, - [input] - ); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (showMentions) { - if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { - e.preventDefault(); - setSelectedMentionIndex((i) => Math.min(i + 1, filteredTargets.length - 1)); - } else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) { - e.preventDefault(); - setSelectedMentionIndex((i) => Math.max(i - 1, 0)); - } else if (e.key === "Tab" || e.key === "Enter") { - e.preventDefault(); - if (filteredTargets[selectedMentionIndex]) { - selectMention(filteredTargets[selectedMentionIndex]); - } - } else if (e.key === "Escape") { - e.preventDefault(); - setShowMentions(false); - } - return; - } - - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }; - - const handleSubmit = async () => { - if (!input.trim() || isSending || disabled) return; - - setIsSending(true); - try { - await onSubmit(selectedTarget, input.trim()); - setInput(""); - } finally { - setIsSending(false); - } - }; - - const selectedTargetLabel = useMemo(() => { - const target = targets.find((t) => t.id === selectedTarget); - if (!target) return "MIND"; - if (target.type === "mind") return "MIND"; - if (target.id.startsWith("new:")) return `NEW ${target.label.replace("new ", "")}`; - return target.label; - }, [selectedTarget, targets]); - - return ( -
- {/* Mention autocomplete */} - {showMentions && filteredTargets.length > 0 && ( -
- {filteredTargets.map((target, i) => ( - - ))} -
- )} - - {/* Input row */} -
- {/* Target indicator */} -
- @ - - {selectedTargetLabel} - -
- - {/* Text input */} -
-