diff --git a/packages/coding-agent/skills/agent-observe/SKILL.md b/packages/coding-agent/skills/agent-observe/SKILL.md new file mode 100644 index 0000000000..bee9b89d76 --- /dev/null +++ b/packages/coding-agent/skills/agent-observe/SKILL.md @@ -0,0 +1,39 @@ +--- +name: agent-observe +description: Read-only observation of active Prime Agent sessions through the local daemon. Use to list agents, inspect session status, and read bounded recent-message previews without mutating other sessions. +--- + +# Agent Observe + +Observe active Prime Agent sessions through the local daemon. This skill is +read-only: it can list sessions, inspect one session, and fetch bounded recent +message previews. It cannot prompt, steer, clear, kill, rename, or otherwise +mutate another session. + +Call directly from the kernel: + +```python +agents = await agent_observe.list_agents() +worker = await agent_observe.get_agent("worker") +recent = await agent_observe.recent_messages("worker", limit=6) +``` + +## API + +- `await agent_observe.list_agents()` returns `current` and `agents`. Each + agent includes active session id, session id, optional name, runtime kind, + cwd, status, streaming state, message count, pending count, and a latest + message preview. +- `await agent_observe.get_agent(target)` returns one agent summary. `target` + is resolved like other live-session selectors: active id, session id/name, or + unambiguous suffix. +- `await agent_observe.recent_messages(target, limit=8, max_chars=800)` + returns up to `limit` recent bounded message previews for the target session. + `limit` must be 1-50, and `max_chars` must be 80-2000. + +## Safety + +- This skill is read-only and exposes no mutation commands. +- Message access is bounded by count and per-message character limit. +- Prefer status and recent previews for orchestration. Ask the user before + using observed context to steer or message another session. diff --git a/packages/coding-agent/skills/agent-observe/pyproject.toml b/packages/coding-agent/skills/agent-observe/pyproject.toml new file mode 100644 index 0000000000..adcbcbfe90 --- /dev/null +++ b/packages/coding-agent/skills/agent-observe/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "agent-observe" +version = "0.1.0" +description = "Read-only Prime Agent session observation skill" +requires-python = ">=3.10" + +[tool.prime_agent.skill] +import = "agent_observe" diff --git a/packages/coding-agent/skills/agent-observe/src/agent_observe/__init__.py b/packages/coding-agent/skills/agent-observe/src/agent_observe/__init__.py new file mode 100644 index 0000000000..a7ad5552b8 --- /dev/null +++ b/packages/coding-agent/skills/agent-observe/src/agent_observe/__init__.py @@ -0,0 +1,52 @@ +"""Read-only Prime Agent session observation skill. + +All session lookup and data access live in the TypeScript daemon. These +functions only call the host bridge exposed inside the Prime Agent IPython +kernel. +""" + +from __future__ import annotations + +from typing import Any + +from rlm import host_request + + +async def list_agents() -> dict[str, Any]: + """List active daemon sessions visible to this agent.""" + return await host_request("agent_observe.list") + + +async def get_agent(target: str) -> dict[str, Any]: + """Read one active session summary by active id, session id/name, or suffix.""" + if not isinstance(target, str): + raise TypeError(f"target must be str, got {type(target).__name__}") + return await host_request("agent_observe.get", {"target": target}) + + +async def recent_messages( + target: str, + limit: int = 8, + max_chars: int = 800, +) -> dict[str, Any]: + """Read bounded recent message previews from an active session. + + Args: + target: Active session id, session id/name, or unambiguous suffix. + limit: Number of recent messages to return. Host validates 1-50. + max_chars: Per-message preview size. Host validates 80-2000. + """ + if not isinstance(target, str): + raise TypeError(f"target must be str, got {type(target).__name__}") + if not isinstance(limit, int): + raise TypeError(f"limit must be int, got {type(limit).__name__}") + if not isinstance(max_chars, int): + raise TypeError(f"max_chars must be int, got {type(max_chars).__name__}") + return await host_request( + "agent_observe.recent", + { + "target": target, + "limit": limit, + "max_chars": max_chars, + }, + ) diff --git a/packages/coding-agent/src/core/agent-observe.ts b/packages/coding-agent/src/core/agent-observe.ts new file mode 100644 index 0000000000..64e6445b5c --- /dev/null +++ b/packages/coding-agent/src/core/agent-observe.ts @@ -0,0 +1,193 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; + +export const AGENT_OBSERVE_SKILL_NAME = "agent-observe"; +export const AGENT_OBSERVE_IMPORT_NAME = "agent_observe"; + +export interface AgentObserveAgentSummary { + activeSessionId: string; + sessionId: string; + sessionName?: string; + runtimeKind?: "top-level" | "subagent"; + cwd: string; + status: string; + isCurrent: boolean; + isStreaming: boolean; + isCompacting: boolean; + attachedClients: number; + messageCount: number; + pendingMessageCount: number; + parentActiveSessionId?: string; + parentSessionId?: string; + rlmChildId?: string; + rlmParentNodeId?: string; + firstMessage?: string; + latestMessage?: AgentObserveMessagePreview; +} + +export interface AgentObserveListResult { + current: AgentObserveAgentSummary; + agents: AgentObserveAgentSummary[]; +} + +export interface AgentObserveAgentSnapshot { + agent: AgentObserveAgentSummary; +} + +export interface AgentObserveRecentMessagesInput { + target: string; + limit?: number; + maxChars?: number; +} + +export interface AgentObserveRecentMessagesResult { + agent: AgentObserveAgentSummary; + messages: AgentObserveMessagePreview[]; + limit: number; + maxChars: number; + truncated: boolean; +} + +export interface AgentObserveMessagePreview { + index: number; + role: string; + timestamp?: number; + text: string; + truncated: boolean; + toolCalls?: string[]; + customType?: string; +} + +export interface AgentObserveController { + listAgents(): AgentObserveListResult; + getAgent(target: string): AgentObserveAgentSnapshot; + recentMessages(input: AgentObserveRecentMessagesInput): AgentObserveRecentMessagesResult; +} + +export function createAgentObserveHostHandlers(controller: AgentObserveController) { + return { + "agent_observe.list": async () => controller.listAgents() as unknown as Record, + "agent_observe.get": async (payload: Record = {}) => { + if (typeof payload.target !== "string") { + throw new Error("agent_observe.get target must be a string"); + } + return controller.getAgent(payload.target) as unknown as Record; + }, + "agent_observe.recent": async (payload: Record = {}) => { + if (typeof payload.target !== "string") { + throw new Error("agent_observe.recent target must be a string"); + } + return controller.recentMessages({ + target: payload.target, + limit: normalizeOptionalInteger(payload.limit, "agent_observe.recent limit"), + maxChars: normalizeOptionalInteger(payload.max_chars ?? payload.maxChars, "agent_observe.recent max_chars"), + }) as unknown as Record; + }, + }; +} + +export function normalizeObserveLimit(limit: number | undefined, defaultLimit = 8): number { + return clampInteger(limit ?? defaultLimit, 1, 50, "agent_observe limit"); +} + +export function normalizeObserveMaxChars(maxChars: number | undefined, defaultMaxChars = 800): number { + return clampInteger(maxChars ?? defaultMaxChars, 80, 2_000, "agent_observe max_chars"); +} + +export function createAgentObserveMessagePreview( + message: AgentMessage, + index: number, + maxChars: number, +): AgentObserveMessagePreview { + const text = messageText(message); + const clipped = truncate(text, maxChars); + const toolCalls = message.role === "assistant" ? assistantToolCalls(message) : undefined; + return { + index, + role: message.role, + ...(message.timestamp ? { timestamp: message.timestamp } : {}), + text: clipped.text, + truncated: clipped.truncated, + ...(toolCalls && toolCalls.length > 0 ? { toolCalls } : {}), + ...(message.role === "custom" ? { customType: message.customType } : {}), + }; +} + +function normalizeOptionalInteger(value: unknown, label: string): number | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new Error(`${label} must be an integer when provided`); + } + return value; +} + +function clampInteger(value: number, min: number, max: number, label: string): number { + if (!Number.isInteger(value)) { + throw new Error(`${label} must be an integer`); + } + if (value < min || value > max) { + throw new Error(`${label} must be between ${min} and ${max}`); + } + return value; +} + +function truncate(text: string, maxChars: number): { text: string; truncated: boolean } { + if (text.length <= maxChars) { + return { text, truncated: false }; + } + return { text: text.slice(0, maxChars), truncated: true }; +} + +function messageText(message: AgentMessage): string { + switch (message.role) { + case "user": + case "assistant": + return contentText(message.content); + case "toolResult": + return contentText(message.content); + case "bashExecution": + return [message.command, message.output].filter(Boolean).join("\n"); + case "custom": + return typeof message.content === "string" ? message.content : contentText(message.content); + case "branchSummary": + return message.summary; + case "compactionSummary": + return message.summary; + default: { + const exhaustive: never = message; + return JSON.stringify(exhaustive); + } + } +} + +function contentText(content: unknown): string { + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return ""; + } + return content + .map((block) => { + if (!block || typeof block !== "object" || !("type" in block)) { + return ""; + } + if (block.type === "text" && "text" in block && typeof block.text === "string") { + return block.text; + } + if (block.type === "image") { + return "[image]"; + } + if (block.type === "toolCall" && "name" in block && typeof block.name === "string") { + return `[tool_call:${block.name}]`; + } + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +function assistantToolCalls(message: Extract): string[] { + return message.content.filter((block) => block.type === "toolCall").map((block) => block.name); +} diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index 46ed2458fd..4a3e2780d5 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Model } from "@earendil-works/pi-ai"; import { getAgentDir } from "../config.js"; +import type { AgentObserveController } from "./agent-observe.js"; import { AuthStorage } from "./auth-storage.js"; import type { SessionStartEvent, ToolDefinition } from "./extensions/index.js"; import { ModelRegistry } from "./model-registry.js"; @@ -50,6 +51,7 @@ export interface AgentSessionCreationOptions { initialActiveToolNames?: string[]; allowedToolNames?: string[]; includeGoals?: boolean; + agentObserveController?: AgentObserveController; rlmDepth?: number; rlmMaxDepth?: number; rlmSessionDir?: string; @@ -209,6 +211,7 @@ export async function createAgentSessionFromServices( initialActiveToolNames: options.initialActiveToolNames, allowedToolNames: options.allowedToolNames, includeGoals: options.includeGoals, + agentObserveController: options.agentObserveController, rlmDepth: options.rlmDepth, rlmMaxDepth: options.rlmMaxDepth, rlmSessionDir: options.rlmSessionDir, diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index cc9d44829d..db6d0673c5 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -49,6 +49,16 @@ import { theme } from "../modes/interactive/theme/theme.js"; import { stripFrontmatter } from "../utils/frontmatter.js"; import { sleep } from "../utils/sleep.js"; import { ensureTool, MISSING_RIPGREP_MESSAGE } from "../utils/tools-manager.js"; +import { + AGENT_OBSERVE_SKILL_NAME, + type AgentObserveAgentSnapshot, + type AgentObserveController, + type AgentObserveListResult, + type AgentObserveRecentMessagesResult, + createAgentObserveHostHandlers, + normalizeObserveLimit, + normalizeObserveMaxChars, +} from "./agent-observe.js"; import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.js"; import { type BashResult, executeBashWithOperations } from "./bash-executor.js"; import { @@ -298,6 +308,8 @@ export interface AgentSessionConfig { * Default: true. */ includeGoals?: boolean; + /** Daemon-backed read-only active-session observation bridge. Omitted for local-only sessions. */ + agentObserveController?: AgentObserveController; /** * Override base tools (useful for custom runtimes). * @@ -684,6 +696,7 @@ export class AgentSession { private _initialActiveToolNames?: string[]; private _allowedToolNames?: Set; private _includeGoals: boolean; + private _agentObserveController?: AgentObserveController; private _baseToolsOverride?: Record; private _sessionStartEvent: SessionStartEvent; private _extensionUIContext?: ExtensionUIContext; @@ -727,6 +740,7 @@ export class AgentSession { this._initialActiveToolNames = config.initialActiveToolNames; this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; this._includeGoals = config.includeGoals ?? true; + this._agentObserveController = config.agentObserveController; this._baseToolsOverride = config.baseToolsOverride; this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" }; this._rlmDepth = config.rlmDepth ?? parseDepth(process.env.RLM_DEPTH, 0, "RLM_DEPTH"); @@ -1321,6 +1335,38 @@ export class AgentSession { } } + handleAgentObserveHostRequest( + type: string, + payload: Record = {}, + ): AgentObserveListResult | AgentObserveAgentSnapshot | AgentObserveRecentMessagesResult { + const controller = this._agentObserveController; + if (!controller) { + throw new Error("agent observation is not available in this session"); + } + switch (type) { + case "agent_observe.list": + return controller.listAgents(); + case "agent_observe.get": { + if (typeof payload.target !== "string") { + throw new Error("agent_observe.get target must be a string"); + } + return controller.getAgent(payload.target); + } + case "agent_observe.recent": { + if (typeof payload.target !== "string") { + throw new Error("agent_observe.recent target must be a string"); + } + return controller.recentMessages({ + target: payload.target, + limit: normalizeObserveLimit(payload.limit as number | undefined), + maxChars: normalizeObserveMaxChars((payload.max_chars ?? payload.maxChars) as number | undefined), + }); + } + default: + throw new Error(`unknown agent observe request type "${type}"`); + } + } + private _createGoalFromHost(objective: string, tokenBudget: number | undefined): GoalState { switch (this._goalState.status) { case "active": @@ -3467,11 +3513,16 @@ export class AgentSession { * skill is withheld when goals are disabled for this session. */ private _modelVisibleSkills(): Skill[] { - const skills = this._resourceLoader.getSkills().skills; + let skills = this._resourceLoader.getSkills().skills; if (this._includeGoals) { - return skills; + // Keep goal skill visible. + } else { + skills = skills.filter((skill) => skill.name !== GOAL_SKILL_NAME); } - return skills.filter((skill) => skill.name !== GOAL_SKILL_NAME); + if (!this._agentObserveController) { + skills = skills.filter((skill) => skill.name !== AGENT_OBSERVE_SKILL_NAME); + } + return skills; } /** Typed handlers for host requests arriving from the IPython kernel comm bridge. */ @@ -3486,6 +3537,22 @@ export class AgentSession { handlers[type] = async (payload) => this.handleGoalHostRequest(type, payload); } } + if (this._agentObserveController) { + Object.assign( + handlers, + createAgentObserveHostHandlers({ + listAgents: () => this.handleAgentObserveHostRequest("agent_observe.list") as AgentObserveListResult, + getAgent: (target) => + this.handleAgentObserveHostRequest("agent_observe.get", { target }) as AgentObserveAgentSnapshot, + recentMessages: (input) => + this.handleAgentObserveHostRequest("agent_observe.recent", { + target: input.target, + limit: input.limit, + max_chars: input.maxChars, + }) as AgentObserveRecentMessagesResult, + }), + ); + } return handlers; } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index c6d9f3d0e7..1ea998b7a9 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -349,6 +349,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} initialActiveToolNames, allowedToolNames, includeGoals, + agentObserveController: options.agentObserveController, extensionRunnerRef, rlmDepth: options.rlmDepth, rlmMaxDepth: options.rlmMaxDepth, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 526cf7a571..6f31e2d8e7 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -1092,6 +1092,7 @@ export async function main(args: string[], options?: MainOptions) { initialActiveToolNames: runtimeSessionOptions?.initialActiveToolNames, allowedToolNames: runtimeSessionOptions?.allowedToolNames, includeGoals: runtimeSessionOptions?.includeGoals, + agentObserveController: runtimeSessionOptions?.agentObserveController, rlmDepth: runtimeSessionOptions?.rlmDepth, rlmMaxDepth: runtimeSessionOptions?.rlmMaxDepth, rlmSessionDir: runtimeSessionOptions?.rlmSessionDir, diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 45205d65e4..35902b4455 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -9,6 +9,17 @@ import { createServer, type Server, type Socket } from "node:net"; import { resolve } from "node:path"; import { VERSION } from "../../config.js"; +import { + type AgentObserveAgentSnapshot, + type AgentObserveAgentSummary, + type AgentObserveController, + type AgentObserveListResult, + type AgentObserveRecentMessagesInput, + type AgentObserveRecentMessagesResult, + createAgentObserveMessagePreview, + normalizeObserveLimit, + normalizeObserveMaxChars, +} from "../../core/agent-observe.js"; import { type AgentSessionRuntimeConfig, mergeAgentSessionRuntimeConfig } from "../../core/agent-session-config.js"; import { AgentSessionRuntime, @@ -318,13 +329,19 @@ class AgentDaemon { // visible in session lists again. sessionManager.appendSessionState({ status: "sleep" }); } + let stateRef: ActiveSessionState | undefined; const runtime = await createAgentSessionRuntime(this.options.createRuntime, { cwd: sessionManager.getCwd(), agentDir: config.agentDir, sessionManager, sessionConfig: config, + sessionOptions: { + agentObserveController: this.createAgentObserveController(() => stateRef), + }, }); - return this.addRuntime(runtime, command.name); + const state = await this.addRuntime(runtime, command.name); + stateRef = state; + return state; } private findSessionBySessionFile(sessionFile: string | undefined): ActiveSessionState | undefined { @@ -389,6 +406,7 @@ class AgentDaemon { if (options.parentSession.sessionFile) { sessionManager.newSession({ parentSession: options.parentSession.sessionFile }); } + let stateRef: ActiveSessionState | undefined; const runtime = await createAgentSessionRuntime(this.options.createRuntime, { cwd: sessionManager.getCwd(), agentDir: parentState.runtime.services.agentDir, @@ -403,6 +421,7 @@ class AgentDaemon { allowedToolNames: options.allowedToolNames, customTools: options.customTools, includeGoals: options.includeGoals, + agentObserveController: this.createAgentObserveController(() => stateRef), rlmDepth: options.rlmDepth, rlmMaxDepth: options.rlmMaxDepth, rlmSessionDir: options.sessionDir, @@ -421,10 +440,91 @@ class AgentDaemon { sessionDir: options.sessionDir, }, }); - await this.addRuntime(runtime); + const state = await this.addRuntime(runtime); + stateRef = state; return runtime; } + private createAgentObserveController(getCurrentState: () => ActiveSessionState | undefined): AgentObserveController { + const requireCurrentState = () => { + const current = getCurrentState(); + if (!current) { + throw new Error("Agent observe state is not ready for this session yet"); + } + return current; + }; + return { + listAgents: () => this.createAgentObserveListResult(requireCurrentState()), + getAgent: (target) => this.createAgentObserveAgentSnapshot(requireCurrentState(), target), + recentMessages: (input) => this.createAgentObserveRecentMessages(requireCurrentState(), input), + }; + } + + private createAgentObserveListResult(currentState: ActiveSessionState): AgentObserveListResult { + return { + current: this.createAgentObserveSummary(currentState, currentState), + agents: [...this.sessions.values()].map((state) => this.createAgentObserveSummary(state, currentState)), + }; + } + + private createAgentObserveAgentSnapshot( + currentState: ActiveSessionState, + target: string, + ): AgentObserveAgentSnapshot { + return { + agent: this.createAgentObserveSummary(this.getSessionState(target), currentState), + }; + } + + private createAgentObserveRecentMessages( + currentState: ActiveSessionState, + input: AgentObserveRecentMessagesInput, + ): AgentObserveRecentMessagesResult { + const targetState = this.getSessionState(input.target); + const limit = normalizeObserveLimit(input.limit); + const maxChars = normalizeObserveMaxChars(input.maxChars); + const messages = targetState.runtime.session.messages; + const startIndex = Math.max(0, messages.length - limit); + return { + agent: this.createAgentObserveSummary(targetState, currentState), + messages: messages + .slice(startIndex) + .map((message, offset) => createAgentObserveMessagePreview(message, startIndex + offset, maxChars)), + limit, + maxChars, + truncated: startIndex > 0, + }; + } + + private createAgentObserveSummary( + state: ActiveSessionState, + currentState: ActiveSessionState, + ): AgentObserveAgentSummary { + const summary = summaryForActiveSession(state); + const messages = state.runtime.session.messages; + const latest = messages.at(-1); + return { + activeSessionId: state.activeSessionId, + sessionId: summary.sessionId, + ...(summary.sessionName ? { sessionName: summary.sessionName } : {}), + ...(summary.runtimeKind ? { runtimeKind: summary.runtimeKind } : {}), + cwd: summary.cwd, + status: summary.status, + isCurrent: state.activeSessionId === currentState.activeSessionId, + isStreaming: summary.isStreaming, + isCompacting: summary.isCompacting, + attachedClients: summary.attachedClients, + messageCount: summary.messageCount, + pendingMessageCount: summary.pendingMessageCount, + ...(summary.parentActiveSessionId ? { parentActiveSessionId: summary.parentActiveSessionId } : {}), + ...(summary.parentSessionId ? { parentSessionId: summary.parentSessionId } : {}), + ...(summary.rlmChildId ? { rlmChildId: summary.rlmChildId } : {}), + ...(summary.rlmParentNodeId ? { rlmParentNodeId: summary.rlmParentNodeId } : {}), + ...(summary.firstMessage ? { firstMessage: summary.firstMessage } : {}), + ...(latest ? { latestMessage: createAgentObserveMessagePreview(latest, messages.length - 1, 240) } : {}), + }; + } + private handleConnection(socket: Socket): void { const client: DaemonSocketClient = { id: createActiveSessionId(), diff --git a/packages/coding-agent/test/agent-observe.test.ts b/packages/coding-agent/test/agent-observe.test.ts new file mode 100644 index 0000000000..741eafa023 --- /dev/null +++ b/packages/coding-agent/test/agent-observe.test.ts @@ -0,0 +1,50 @@ +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; +import { + createAgentObserveMessagePreview, + normalizeObserveLimit, + normalizeObserveMaxChars, +} from "../src/core/agent-observe.js"; + +describe("agent observe helpers", () => { + it("creates bounded text previews", () => { + const preview = createAgentObserveMessagePreview( + { + role: "user", + content: [{ type: "text", text: "abcdefghijklmnopqrstuvwxyz" }], + timestamp: 123, + }, + 4, + 8, + ); + + expect(preview).toEqual({ + index: 4, + role: "user", + timestamp: 123, + text: "abcdefgh", + truncated: true, + }); + }); + + it("includes assistant tool call names without exposing arguments", () => { + const preview = createAgentObserveMessagePreview( + fauxAssistantMessage(fauxToolCall("bash", { command: "secret" }), { stopReason: "toolUse" }), + 2, + 200, + ); + + expect(preview.text).toBe("[tool_call:bash]"); + expect(preview.toolCalls).toEqual(["bash"]); + expect(preview.text).not.toContain("secret"); + }); + + it("validates bounds", () => { + expect(normalizeObserveLimit(undefined)).toBe(8); + expect(normalizeObserveLimit(50)).toBe(50); + expect(() => normalizeObserveLimit(0)).toThrow("between 1 and 50"); + expect(normalizeObserveMaxChars(undefined)).toBe(800); + expect(normalizeObserveMaxChars(80)).toBe(80); + expect(() => normalizeObserveMaxChars(2_001)).toThrow("between 80 and 2000"); + }); +}); diff --git a/packages/coding-agent/test/agent-session-services.test.ts b/packages/coding-agent/test/agent-session-services.test.ts new file mode 100644 index 0000000000..b5552e0196 --- /dev/null +++ b/packages/coding-agent/test/agent-session-services.test.ts @@ -0,0 +1,119 @@ +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { registerFauxProvider } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AgentObserveController } from "../src/core/agent-observe.js"; +import { createAgentSessionFromServices, createAgentSessionServices } from "../src/core/agent-session-services.js"; +import { AuthStorage } from "../src/core/auth-storage.js"; +import { SessionManager } from "../src/core/session-manager.js"; + +describe("createAgentSessionFromServices", () => { + const cleanupPaths: string[] = []; + const unregisters: Array<() => void> = []; + + afterEach(() => { + while (unregisters.length > 0) { + unregisters.pop()?.(); + } + while (cleanupPaths.length > 0) { + const path = cleanupPaths.pop(); + if (path && existsSync(path)) { + rmSync(path, { recursive: true, force: true }); + } + } + }); + + it("forwards daemon-backed agent observe controllers into AgentSession", async () => { + const tempDir = join(tmpdir(), `pi-session-services-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + cleanupPaths.push(tempDir); + + const faux = registerFauxProvider(); + unregisters.push(() => faux.unregister()); + + const authStorage = AuthStorage.inMemory(); + authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key"); + const services = await createAgentSessionServices({ + cwd: tempDir, + agentDir: tempDir, + authStorage, + resourceLoaderOptions: { + noSkills: true, + noPromptTemplates: true, + noThemes: true, + }, + }); + services.modelRegistry.registerProvider(faux.getModel().provider, { + baseUrl: faux.getModel().baseUrl, + apiKey: "faux-key", + api: faux.api, + models: faux.models, + }); + + const agentObserveController: AgentObserveController = { + listAgents: () => ({ + current: { + activeSessionId: "current", + sessionId: "session-current", + cwd: tempDir, + status: "idle", + isCurrent: true, + isStreaming: false, + isCompacting: false, + attachedClients: 0, + messageCount: 0, + pendingMessageCount: 0, + }, + agents: [], + }), + getAgent: (target) => ({ + agent: { + activeSessionId: target, + sessionId: "session-worker", + cwd: tempDir, + status: "idle", + isCurrent: false, + isStreaming: false, + isCompacting: false, + attachedClients: 0, + messageCount: 0, + pendingMessageCount: 0, + }, + }), + recentMessages: (input) => ({ + agent: { + activeSessionId: input.target, + sessionId: "session-worker", + cwd: tempDir, + status: "idle", + isCurrent: false, + isStreaming: false, + isCompacting: false, + attachedClients: 0, + messageCount: 0, + pendingMessageCount: 0, + }, + messages: [], + limit: input.limit ?? 8, + maxChars: input.maxChars ?? 800, + truncated: false, + }), + }; + + const { session } = await createAgentSessionFromServices({ + services, + sessionManager: SessionManager.create(tempDir, join(tempDir, "sessions")), + model: faux.getModel(), + agentObserveController, + }); + + try { + expect(session.handleAgentObserveHostRequest("agent_observe.list")).toMatchObject({ + current: { activeSessionId: "current" }, + }); + } finally { + session.dispose(); + } + }); +}); diff --git a/packages/coding-agent/test/builtin-skills.test.ts b/packages/coding-agent/test/builtin-skills.test.ts index a8690222e0..fd40e21035 100644 --- a/packages/coding-agent/test/builtin-skills.test.ts +++ b/packages/coding-agent/test/builtin-skills.test.ts @@ -172,6 +172,15 @@ describe("builtin skills", () => { expect(goal?.kind === "python" && goal.python.importName).toBe("goal"); }); + it("ships the agent-observe skill as a python skill importable as `agent_observe`", () => { + const { skills } = loadSkillsFromDir({ dir: getBundledSkillsDir(), source: "builtin" }); + + const agentObserve = skills.find((s) => s.name === "agent-observe"); + expect(agentObserve).toBeDefined(); + expect(agentObserve?.kind).toBe("python"); + expect(agentObserve?.kind === "python" && agentObserve.python.importName).toBe("agent_observe"); + }); + it("ships the edit skill as a python skill importable as `edit`", () => { const { skills } = loadSkillsFromDir({ dir: getBundledSkillsDir(), source: "builtin" }); diff --git a/packages/coding-agent/test/kernel-agent-observe-skill.test.ts b/packages/coding-agent/test/kernel-agent-observe-skill.test.ts new file mode 100644 index 0000000000..0608bd6c0a --- /dev/null +++ b/packages/coding-agent/test/kernel-agent-observe-skill.test.ts @@ -0,0 +1,113 @@ +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getBundledSkillsDir } from "../src/config.js"; +import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; +import { IpythonKernelProvisioner } from "../src/core/tools/ipython.js"; + +function bundledAgentObserveSkill(): PythonSkillRuntimeInfo { + const packagePath = join(getBundledSkillsDir(), "agent-observe"); + return { + name: "agent-observe", + importName: "agent_observe", + packagePath, + pyprojectPath: join(packagePath, "pyproject.toml"), + }; +} + +describe("agent-observe skill over the kernel host bridge", () => { + let tempDir: string; + let provisioner: IpythonKernelProvisioner | undefined; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-agent-observe-skill-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(async () => { + await provisioner?.dispose(); + provisioner = undefined; + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("lists agents and reads bounded recent messages", async () => { + const requests: Array<{ type: string; payload: Record }> = []; + provisioner = new IpythonKernelProvisioner(tempDir, { + pythonSkills: [bundledAgentObserveSkill()], + hostHandlers: { + "agent_observe.list": async (payload) => { + requests.push({ type: "agent_observe.list", payload }); + return { + current: { activeSessionId: "alpha", sessionId: "session-alpha", isCurrent: true }, + agents: [ + { activeSessionId: "alpha", sessionId: "session-alpha", sessionName: "Orchestrator" }, + { activeSessionId: "beta", sessionId: "session-beta", sessionName: "Worker" }, + ], + }; + }, + "agent_observe.get": async (payload) => { + requests.push({ type: "agent_observe.get", payload }); + return { agent: { activeSessionId: payload.target, sessionId: "session-beta", status: "model" } }; + }, + "agent_observe.recent": async (payload) => { + requests.push({ type: "agent_observe.recent", payload }); + return { + agent: { activeSessionId: payload.target, sessionId: "session-beta" }, + messages: [{ index: 1, role: "assistant", text: "working", truncated: false }], + limit: payload.limit, + maxChars: payload.max_chars, + truncated: false, + }; + }, + }, + }); + + const manager = await provisioner.ensure(); + const result = await manager.execute(` +import json +agents = await agent_observe.list_agents() +agent = await agent_observe.get_agent("beta") +recent = await agent_observe.recent_messages("beta", limit=3, max_chars=120) +print(json.dumps({"agents": agents, "agent": agent, "recent": recent}, sort_keys=True)) +`); + + expect(result.status).toBe("ok"); + const output = JSON.parse(result.stdout.trim()); + expect(output.agents.agents).toHaveLength(2); + expect(output.agent.agent).toMatchObject({ activeSessionId: "beta", status: "model" }); + expect(output.recent.messages).toEqual([{ index: 1, role: "assistant", text: "working", truncated: false }]); + expect(requests.map((request) => request.type)).toEqual([ + "agent_observe.list", + "agent_observe.get", + "agent_observe.recent", + ]); + expect(requests[2].payload).toMatchObject({ + type: "agent_observe.recent", + target: "beta", + limit: 3, + max_chars: 120, + }); + }); + + it("validates argument types before sending to the host", async () => { + provisioner = new IpythonKernelProvisioner(tempDir, { + pythonSkills: [bundledAgentObserveSkill()], + hostHandlers: { + "agent_observe.get": async () => { + throw new Error("should not reach host"); + }, + }, + }); + + const manager = await provisioner.ensure(); + const result = await manager.execute(` +try: + await agent_observe.get_agent(123) +except TypeError as error: + print(f"TypeError: {error}") +`); + expect(result.status).toBe("ok"); + expect(result.stdout.trim()).toBe("TypeError: target must be str, got int"); + }); +}); diff --git a/packages/coding-agent/test/suite/agent-session-observe.test.ts b/packages/coding-agent/test/suite/agent-session-observe.test.ts new file mode 100644 index 0000000000..cfec58f113 --- /dev/null +++ b/packages/coding-agent/test/suite/agent-session-observe.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentObserveController } from "../../src/core/agent-observe.js"; +import { createHarness } from "./harness.js"; + +function createController(): AgentObserveController { + return { + listAgents: vi.fn(() => ({ + current: { + activeSessionId: "alpha", + sessionId: "session-alpha", + cwd: "/tmp/project", + status: "idle", + isCurrent: true, + isStreaming: false, + isCompacting: false, + attachedClients: 0, + messageCount: 1, + pendingMessageCount: 0, + }, + agents: [], + })), + getAgent: vi.fn((target) => ({ + agent: { + activeSessionId: target, + sessionId: "session-beta", + cwd: "/tmp/project", + status: "model", + isCurrent: false, + isStreaming: true, + isCompacting: false, + attachedClients: 1, + messageCount: 3, + pendingMessageCount: 0, + }, + })), + recentMessages: vi.fn((input) => ({ + agent: { + activeSessionId: input.target, + sessionId: "session-beta", + cwd: "/tmp/project", + status: "model", + isCurrent: false, + isStreaming: true, + isCompacting: false, + attachedClients: 1, + messageCount: 3, + pendingMessageCount: 0, + }, + messages: [{ index: 2, role: "assistant", text: "working", truncated: false }], + limit: input.limit ?? 8, + maxChars: input.maxChars ?? 800, + truncated: false, + })), + }; +} + +describe("AgentSession agent observe host requests", () => { + it("routes list, get, and recent requests to the read-only controller", async () => { + const controller = createController(); + const harness = await createHarness({ agentObserveController: controller }); + try { + expect(harness.session.handleAgentObserveHostRequest("agent_observe.list")).toMatchObject({ + current: { activeSessionId: "alpha" }, + }); + expect(harness.session.handleAgentObserveHostRequest("agent_observe.get", { target: "beta" })).toMatchObject({ + agent: { activeSessionId: "beta", status: "model" }, + }); + expect( + harness.session.handleAgentObserveHostRequest("agent_observe.recent", { + target: "beta", + limit: 3, + max_chars: 120, + }), + ).toMatchObject({ + agent: { activeSessionId: "beta" }, + messages: [{ text: "working" }], + limit: 3, + maxChars: 120, + }); + expect(controller.listAgents).toHaveBeenCalledTimes(1); + expect(controller.getAgent).toHaveBeenCalledWith("beta"); + expect(controller.recentMessages).toHaveBeenCalledWith({ target: "beta", limit: 3, maxChars: 120 }); + } finally { + harness.cleanup(); + } + }); + + it("rejects malformed and unknown observe requests", async () => { + const harness = await createHarness({ agentObserveController: createController() }); + try { + expect(() => harness.session.handleAgentObserveHostRequest("agent_observe.get", {})).toThrow( + "target must be a string", + ); + expect(() => + harness.session.handleAgentObserveHostRequest("agent_observe.recent", { + target: "beta", + limit: 0, + }), + ).toThrow("between 1 and 50"); + expect(() => harness.session.handleAgentObserveHostRequest("agent_observe.delete")).toThrow( + "unknown agent observe request", + ); + } finally { + harness.cleanup(); + } + }); + + it("is unavailable without a daemon-backed controller", async () => { + const harness = await createHarness(); + try { + expect(() => harness.session.handleAgentObserveHostRequest("agent_observe.list")).toThrow( + "agent observation is not available", + ); + } finally { + harness.cleanup(); + } + }); +}); diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index 16a8182ec4..8cd8fb5df7 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -9,6 +9,7 @@ import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core"; import { Agent } from "@earendil-works/pi-agent-core"; import type { FauxModelDefinition, FauxProviderRegistration, FauxResponseStep, Model } from "@earendil-works/pi-ai"; import { registerFauxProvider } from "@earendil-works/pi-ai"; +import type { AgentObserveController } from "../../src/core/agent-observe.js"; import { AgentSession, type AgentSessionEvent } from "../../src/core/agent-session.js"; import { AuthStorage } from "../../src/core/auth-storage.js"; import type { ExtensionRunner } from "../../src/core/extensions/index.js"; @@ -63,6 +64,7 @@ export interface HarnessOptions { resourceLoader?: ResourceLoader; extensionFactories?: Array; withConfiguredAuth?: boolean; + agentObserveController?: AgentObserveController; } export interface Harness { @@ -172,6 +174,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise