diff --git a/apps/agent-test-cli/src/agent-test-cli.ts b/apps/agent-test-cli/src/agent-test-cli.ts index 6048707..d0dd1f7 100644 --- a/apps/agent-test-cli/src/agent-test-cli.ts +++ b/apps/agent-test-cli/src/agent-test-cli.ts @@ -15,20 +15,8 @@ interface AuthSession { } } -interface ProposedAction { - id: string - title: string - description: string - sourceId?: string - actionId?: string - params?: unknown - requiresConfirmation: true - createdAt: string -} - interface QueryResponse { message: string - proposedActions: ProposedAction[] } interface QueryToolDefinition { @@ -187,7 +175,6 @@ async function askAgent(backendUrl: string, cookies: CookieJar, message: string) } console.log(`\nagent> ${data.message || "(no message)"}`) - printProposedActions(data.proposedActions) console.log("") } @@ -366,22 +353,6 @@ function printHelp(): void { console.log(" /quit Exit\n") } -function printProposedActions(actions: ProposedAction[]): void { - if (actions.length === 0) return - - console.log("\nProposed actions:") - for (const action of actions) { - console.log(`- ${action.title} (${action.id})`) - console.log(` ${action.description}`) - if (action.sourceId || action.actionId) { - console.log(` source=${action.sourceId ?? "-"} action=${action.actionId ?? "-"}`) - } - if (action.params !== undefined) { - console.log(` params=${JSON.stringify(action.params)}`) - } - } -} - function askRequired( label: string, defaultValue?: string, @@ -579,9 +550,7 @@ function isAuthSession(value: unknown): value is AuthSession { function isQueryResponse(value: unknown): value is QueryResponse { if (!isJsonObject(value)) return false - if (typeof value.message !== "string") return false - if (!Array.isArray(value.proposedActions)) return false - return value.proposedActions.every(isProposedAction) + return typeof value.message === "string" } function isQueryToolsResponse(value: unknown): value is QueryToolsResponse { @@ -616,20 +585,6 @@ function isSourceActionDefinition(value: unknown): value is { id: string; descri ) } -function isProposedAction(value: unknown): value is ProposedAction { - if (!isJsonObject(value)) return false - - return ( - typeof value.id === "string" && - typeof value.title === "string" && - typeof value.description === "string" && - (value.sourceId === undefined || typeof value.sourceId === "string") && - (value.actionId === undefined || typeof value.actionId === "string") && - value.requiresConfirmation === true && - typeof value.createdAt === "string" - ) -} - function isJsonObject(value: unknown): value is JsonObject { return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/apps/freya-backend/src/agent/debug-tools.test.ts b/apps/freya-backend/src/agent/debug-tools.test.ts index 524ed94..6af94a5 100644 --- a/apps/freya-backend/src/agent/debug-tools.test.ts +++ b/apps/freya-backend/src/agent/debug-tools.test.ts @@ -57,6 +57,28 @@ describe("query debug tools", () => { }, ]) }) + + test("executes source action directly", async () => { + const tools = createTestDebugTools() + const params = { title: "Buy tea" } + + const result = await tools.execute("user-1", "freya_execute_action", { + sourceId: "freya.reminders", + actionId: "create-reminder", + params, + }) + + expect(result).toEqual({ + ok: true, + sourceId: "freya.reminders", + actionId: "create-reminder", + result: { + sourceId: "freya.reminders", + actionId: "create-reminder", + params, + }, + }) + }) }) function createTestDebugTools() { @@ -109,6 +131,16 @@ function createTestDebugTools() { async listActions(sourceId: string) { return actions[sourceId] ?? {} }, + async executeAction(sourceId: string, actionId: string, params: unknown) { + const sourceActions = actions[sourceId] + if (!sourceActions) { + throw new Error(`Source not found: ${sourceId}`) + } + if (!(actionId in sourceActions)) { + throw new Error(`Action "${actionId}" not found on source "${sourceId}"`) + } + return { sourceId, actionId, params } + }, }, hasSource(sourceId: string) { return sourceId in actions diff --git a/apps/freya-backend/src/agent/debug-tools.ts b/apps/freya-backend/src/agent/debug-tools.ts index 0e9a7a1..dde0671 100644 --- a/apps/freya-backend/src/agent/debug-tools.ts +++ b/apps/freya-backend/src/agent/debug-tools.ts @@ -1,7 +1,6 @@ import { contextKey, type ContextKeyPart } from "@freya/core" import type { UserSessionManager } from "../session/index.ts" -import type { ProposedAction } from "./query-agent.ts" type ToolParams = Record @@ -23,7 +22,7 @@ const FreyaGetContextTool = "freya_get_context" const FreyaListContextTool = "freya_list_context" const FreyaGetSourceDataTool = "freya_get_source_data" const FreyaGetFeedItemTool = "freya_get_feed_item" -const FreyaProposeActionTool = "freya_propose_action" +const FreyaExecuteActionTool = "freya_execute_action" export function createQueryDebugTools(sessionManager: UserSessionManager): QueryDebugTools { return new DefaultQueryDebugTools(sessionManager) @@ -86,14 +85,12 @@ class DefaultQueryDebugTools implements QueryDebugTools { }, }, { - name: FreyaProposeActionTool, - label: "Propose FREYA Action", - description: "Create a proposed action object without executing it.", + name: FreyaExecuteActionTool, + label: "Execute FREYA Action", + description: "Execute an available source action immediately.", parameters: { - title: "string", - description: "string", - sourceId: "string?", - actionId: "string?", + sourceId: "string", + actionId: "string", params: "unknown?", }, }, @@ -114,8 +111,8 @@ class DefaultQueryDebugTools implements QueryDebugTools { return this.listContext(userId) case FreyaGetSourceDataTool: return this.getSourceData(userId, expectToolParams(params, ["sourceId"])) - case FreyaProposeActionTool: - return proposeAction(expectToolParams(params, ["title", "description"])) + case FreyaExecuteActionTool: + return this.executeAction(userId, expectToolParams(params, ["sourceId", "actionId"])) default: throw new Error(`Unknown debug tool: ${toolName}`) } @@ -322,27 +319,20 @@ class DefaultQueryDebugTools implements QueryDebugTools { errors, } } -} -function proposeAction(params: ToolParams): unknown { - const sourceId = optionalString(params, "sourceId") - const actionId = optionalString(params, "actionId") - const action: ProposedAction = { - id: crypto.randomUUID(), - title: expectString(params, "title"), - description: expectString(params, "description"), - requiresConfirmation: true, - createdAt: new Date().toISOString(), - ...(sourceId ? { sourceId } : {}), - ...(actionId ? { actionId } : {}), - ...("params" in params ? { params: params.params } : {}), - } + private async executeAction(userId: string, params: ToolParams): Promise { + const sourceId = expectString(params, "sourceId") + const actionId = expectString(params, "actionId") + const actionParams = "params" in params ? params.params : undefined + const userSession = await this.sessionManager.getOrCreate(userId) + const result = await userSession.engine.executeAction(sourceId, actionId, actionParams) - return { - ok: true, - proposedActionId: action.id, - requiresConfirmation: true, - proposedAction: action, + return { + ok: true, + sourceId, + actionId, + result: result ?? null, + } } } diff --git a/apps/freya-backend/src/agent/http.test.ts b/apps/freya-backend/src/agent/http.test.ts index f596921..d5c3aa3 100644 --- a/apps/freya-backend/src/agent/http.test.ts +++ b/apps/freya-backend/src/agent/http.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Hono } from "hono" import type { QueryDebugTools, QueryDebugToolDefinition } from "./debug-tools.ts" -import type { ProposedAction, QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts" +import type { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts" import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts" import { registerAgentHttpHandlers, registerDebugAgentHttpHandlers } from "./http.ts" @@ -80,21 +80,10 @@ describe("POST /api/agent", () => { expect(res.status).toBe(401) }) - test("collects text deltas and proposed actions", async () => { - const action: ProposedAction = { - id: "proposal-1", - title: "Update commute line", - description: "Set the user's commute line to Victoria.", - sourceId: "freya.tfl", - actionId: "set-lines-of-interest", - params: ["victoria"], - requiresConfirmation: true, - createdAt: "2026-06-12T12:00:00.000Z", - } + test("collects text deltas", async () => { const agent = new FakeQueryAgent([ { type: "text_delta", text: "You should " }, { type: "text_delta", text: "leave at 8:30." }, - { type: "action_proposed", action }, { type: "done" }, ]) const app = buildTestApp(agent, "user-1") @@ -112,10 +101,8 @@ describe("POST /api/agent", () => { const body = (await res.json()) as { message: string - proposedActions: ProposedAction[] } expect(body.message).toBe("You should leave at 8:30.") - expect(body.proposedActions).toEqual([action]) }) test("returns 400 for invalid body", async () => { diff --git a/apps/freya-backend/src/agent/pi-query-agent.ts b/apps/freya-backend/src/agent/pi-query-agent.ts index 495d902..989e64e 100644 --- a/apps/freya-backend/src/agent/pi-query-agent.ts +++ b/apps/freya-backend/src/agent/pi-query-agent.ts @@ -10,7 +10,7 @@ import { import { tmpdir } from "node:os" import type { UserSessionManager } from "../session/index.ts" -import type { ProposedAction, QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts" +import type { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts" import { InMemoryResourceLoader } from "./in-memory-resource-loader.ts" import defaultSystemPrompt from "./prompts/system.txt" @@ -28,24 +28,18 @@ export interface PiQueryAgentConfig { apiKey?: string cwd?: string systemPrompt?: string - clock?: () => Date -} - -interface ActiveRun { - proposedActions: ProposedAction[] } export class PiQueryAgent implements QueryAgent { private readonly sessionManager: UserSessionManager private readonly cwd: string private readonly systemPrompt: string - private readonly clock: () => Date private readonly modelProvider: string private readonly modelId: string private readonly apiKey: string | undefined private readonly sessions = new Map() private readonly pendingSessions = new Map>() - private readonly activeRuns = new Map() + private readonly activeRuns = new Map() constructor(config: PiQueryAgentConfig) { this.sessionManager = config.sessionManager @@ -54,7 +48,6 @@ export class PiQueryAgent implements QueryAgent { this.apiKey = config.apiKey this.cwd = config.cwd ?? tmpdir() this.systemPrompt = config.systemPrompt ?? defaultSystemPrompt - this.clock = config.clock ?? (() => new Date()) } async *ask(input: QueryAgentAsk): AsyncIterable { @@ -66,7 +59,7 @@ export class PiQueryAgent implements QueryAgent { return } - const run: ActiveRun = { proposedActions: [] } + const run = Symbol(input.userId) this.activeRuns.set(input.userId, run) let session: PiSession @@ -117,9 +110,6 @@ export class PiQueryAgent implements QueryAgent { void this.runPrompt(session, input) .then(() => { if (runFailed) return - for (const action of run.proposedActions) { - pushRunEvent({ type: "action_proposed", action }) - } pushRunEvent({ type: "done" }) }) .catch((err: unknown) => { @@ -161,7 +151,7 @@ export class PiQueryAgent implements QueryAgent { this.activeRuns.clear() } - private clearActiveRun(userId: string, run: ActiveRun): void { + private clearActiveRun(userId: string, run: symbol): void { if (this.activeRuns.get(userId) === run) { this.activeRuns.delete(userId) } @@ -214,10 +204,6 @@ export class PiQueryAgent implements QueryAgent { customTools: createFreyaAgentTools({ userId, sessionManager: this.sessionManager, - clock: this.clock, - proposeAction: (action) => { - this.activeRuns.get(userId)?.proposedActions.push(action) - }, }), tools: [...FREYA_AGENT_TOOL_NAMES], }) diff --git a/apps/freya-backend/src/agent/prompts/system.txt b/apps/freya-backend/src/agent/prompts/system.txt index 868fa3f..4bde488 100644 --- a/apps/freya-backend/src/agent/prompts/system.txt +++ b/apps/freya-backend/src/agent/prompts/system.txt @@ -1,5 +1,7 @@ You are Freya. You are a digital companion created by Kenneth. His twitter is @kennethnym. +You have access to user data via the context graph. It stores the latest snapshot of all user data and context. +It reactively updates based on external events, such as, but not exclusively, when the user moves, when a new email arrives, when weather updates are available, and when transit alerts are issued. @@ -15,9 +17,9 @@ freya_list_context: when you need to inspect all current context graph entries. freya_get_source_data: when you need current feed items, context entries, actions, or errors for a specific source ID. -freya_propose_action: when the user asks to change state or when you recommend a concrete action that should be confirmed first. This tool only proposes an action. It does not execute the action. +freya_execute_action: when the user asks you to perform an available source action, or when the source action is non-mutating and tool-like. This executes immediately. -if you need more information to answer user's query, call freya_propose_action with freya.web-search source id. +If you need more information to answer user's query, call freya_execute_action with sourceId "freya.web-search", actionId "search", and params containing query and numResults, for example {"query":"latest relevant information","numResults":5}. diff --git a/apps/freya-backend/src/agent/query-agent.ts b/apps/freya-backend/src/agent/query-agent.ts index 73c141b..20d16d4 100644 --- a/apps/freya-backend/src/agent/query-agent.ts +++ b/apps/freya-backend/src/agent/query-agent.ts @@ -3,22 +3,10 @@ export interface QueryAgentAsk { message: string } -export interface ProposedAction { - id: string - title: string - description: string - sourceId?: string - actionId?: string - params?: unknown - requiresConfirmation: true - createdAt: string -} - export type QueryAgentEvent = | { type: "text_delta"; text: string } | { type: "tool_start"; toolName: string } | { type: "tool_end"; toolName: string; ok: boolean } - | { type: "action_proposed"; action: ProposedAction } | { type: "done" } | { type: "error"; message: string } @@ -30,7 +18,6 @@ export interface QueryAgent { export interface QueryAgentResponse { message: string - proposedActions: ProposedAction[] } export class QueryAgentError extends Error { @@ -45,16 +32,12 @@ export async function collectQueryAgentResponse( input: QueryAgentAsk, ): Promise { let message = "" - const proposedActions: ProposedAction[] = [] for await (const event of agent.ask(input)) { switch (event.type) { case "text_delta": message += event.text break - case "action_proposed": - proposedActions.push(event.action) - break case "error": throw new QueryAgentError(event.message) case "tool_start": @@ -64,5 +47,5 @@ export async function collectQueryAgentResponse( } } - return { message, proposedActions } + return { message } } diff --git a/apps/freya-backend/src/agent/tools.ts b/apps/freya-backend/src/agent/tools.ts index 631f0ac..6a2a5b1 100644 --- a/apps/freya-backend/src/agent/tools.ts +++ b/apps/freya-backend/src/agent/tools.ts @@ -3,15 +3,12 @@ import { Type } from "typebox" import type { UserSessionManager } from "../session/index.ts" import type { QueryDebugTools } from "./debug-tools.ts" -import type { ProposedAction } from "./query-agent.ts" import { createQueryDebugTools } from "./debug-tools.ts" interface CreateFreyaAgentToolsConfig { userId: string sessionManager: UserSessionManager - clock: () => Date - proposeAction(action: ProposedAction): void } export const FREYA_QUERY_CONTEXT_TOOL = "freya_query_context" @@ -20,7 +17,7 @@ export const FREYA_GET_CONTEXT_TOOL = "freya_get_context" export const FREYA_LIST_CONTEXT_TOOL = "freya_list_context" export const FREYA_GET_SOURCE_DATA_TOOL = "freya_get_source_data" export const FREYA_GET_FEED_ITEM_TOOL = "freya_get_feed_item" -export const FREYA_PROPOSE_ACTION_TOOL = "freya_propose_action" +export const FREYA_EXECUTE_ACTION_TOOL = "freya_execute_action" export const FREYA_AGENT_TOOL_NAMES = [ FREYA_LIST_SOURCES_TOOL, @@ -29,7 +26,7 @@ export const FREYA_AGENT_TOOL_NAMES = [ FREYA_QUERY_CONTEXT_TOOL, FREYA_LIST_CONTEXT_TOOL, FREYA_GET_SOURCE_DATA_TOOL, - FREYA_PROPOSE_ACTION_TOOL, + FREYA_EXECUTE_ACTION_TOOL, ] export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) { @@ -121,28 +118,21 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) { execute: async (_toolCallId, params) => executeGetSourceDataTool(config, params), }) - const proposeActionTool = defineTool({ - name: FREYA_PROPOSE_ACTION_TOOL, - label: "Propose FREYA Action", - description: "Create a proposed action for the user to review. This never executes the action.", + const executeActionTool = defineTool({ + name: FREYA_EXECUTE_ACTION_TOOL, + label: "Execute FREYA Action", + description: + "Execute an available FREYA source action immediately without creating a proposal.", parameters: Type.Object({ - title: Type.String({ description: "Short user-facing action title." }), - description: Type.String({ - description: "What will happen if the user confirms this action.", - }), - sourceId: Type.Optional( - Type.String({ description: "Source ID that should execute the action, if known." }), - ), - actionId: Type.Optional( - Type.String({ description: "Source action ID to execute after confirmation, if known." }), - ), + sourceId: Type.String({ description: "Source ID that should execute the action." }), + actionId: Type.String({ description: "Source action ID to execute." }), params: Type.Optional( Type.Unknown({ - description: "Parameters to pass to the source action after confirmation.", + description: "Parameters to pass to the source action.", }), ), }), - execute: async (_toolCallId, params) => executeProposeActionTool(config, params), + execute: async (_toolCallId, params) => executeActionToolCall(config, params), }) return [ @@ -152,7 +142,7 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) { queryContextTool, listContextTool, getSourceDataTool, - proposeActionTool, + executeActionTool, ] } @@ -285,28 +275,26 @@ async function executeGetSourceDataTool( } } -function executeProposeActionTool( +async function executeActionToolCall( config: CreateFreyaAgentToolsConfig, params: { - title: string - description: string - sourceId?: string - actionId?: string + sourceId: string + actionId: string params?: unknown }, ) { - const action: ProposedAction = { - id: crypto.randomUUID(), - title: params.title, - description: params.description, - requiresConfirmation: true, - createdAt: config.clock().toISOString(), - ...(params.sourceId ? { sourceId: params.sourceId } : {}), - ...(params.actionId ? { actionId: params.actionId } : {}), - ...(params.params !== undefined ? { params: params.params } : {}), - } + const userSession = await config.sessionManager.getOrCreate(config.userId) + const result = await userSession.engine.executeAction( + params.sourceId, + params.actionId, + params.params, + ) - config.proposeAction(action) + const actionExecution = { + sourceId: params.sourceId, + actionId: params.actionId, + result: result ?? null, + } return { content: [ @@ -314,11 +302,10 @@ function executeProposeActionTool( type: "text" as const, text: JSON.stringify({ ok: true, - proposedActionId: action.id, - requiresConfirmation: true, + ...actionExecution, }), }, ], - details: { proposedAction: action }, + details: { actionExecution }, } }