Compare commits

..

1 Commits

Author SHA1 Message Date
be1bf43f7d refactor: split query agent toolbox 2026-06-15 20:49:36 +01:00
23 changed files with 780 additions and 343 deletions

View File

@@ -12,8 +12,6 @@ BETTER_AUTH_URL=http://localhost:3000
# OpenRouter (LLM feed enhancement) # OpenRouter (LLM feed enhancement)
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
# Optional: override the default model (default: openai/gpt-4.1-mini)
# OPENROUTER_MODEL=openai/gpt-4.1-mini
# Apple WeatherKit credentials # Apple WeatherKit credentials
WEATHERKIT_PRIVATE_KEY= WEATHERKIT_PRIVATE_KEY=

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Hono } from "hono" import { Hono } from "hono"
import type { UserSessionManager } from "../session/index.ts"
import type { QueryDebugTools, QueryDebugToolDefinition } from "./debug-tools.ts" import type { QueryDebugTools, QueryDebugToolDefinition } from "./debug-tools.ts"
import type { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts" import type { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
@@ -24,8 +25,6 @@ class FakeQueryAgent implements QueryAgent {
} }
} }
disposeUser(): void {}
dispose(): void {} dispose(): void {}
} }
@@ -52,8 +51,14 @@ class FakeDebugTools implements QueryDebugTools {
function buildTestApp(queryAgent: QueryAgent, userId?: string) { function buildTestApp(queryAgent: QueryAgent, userId?: string) {
const app = new Hono() const app = new Hono()
const sessionManager = {
async getOrCreate() {
return { agent: queryAgent }
},
} as unknown as UserSessionManager
registerAgentHttpHandlers(app, { registerAgentHttpHandlers(app, {
queryAgent, sessionManager,
authSessionMiddleware: mockAuthSessionMiddleware(userId), authSessionMiddleware: mockAuthSessionMiddleware(userId),
}) })
return app return app

View File

@@ -4,14 +4,14 @@ import { type } from "arktype"
import { createMiddleware } from "hono/factory" import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts" import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { UserSessionManager } from "../session/index.ts"
import type { QueryDebugTools } from "./debug-tools.ts" import type { QueryDebugTools } from "./debug-tools.ts"
import type { QueryAgent } from "./query-agent.ts"
import { collectQueryAgentResponse, QueryAgentError } from "./query-agent.ts" import { collectQueryAgentResponse, QueryAgentError } from "./query-agent.ts"
type Env = { type Env = {
Variables: { Variables: {
queryAgent: QueryAgent sessionManager: UserSessionManager
} }
} }
@@ -22,7 +22,7 @@ type DebugEnv = {
} }
interface AgentHttpHandlersDeps { interface AgentHttpHandlersDeps {
queryAgent: QueryAgent sessionManager: UserSessionManager
authSessionMiddleware: AuthSessionMiddleware authSessionMiddleware: AuthSessionMiddleware
} }
@@ -39,10 +39,10 @@ const AgentAskRequestBody = type({
export function registerAgentHttpHandlers( export function registerAgentHttpHandlers(
app: Hono, app: Hono,
{ queryAgent, authSessionMiddleware }: AgentHttpHandlersDeps, { sessionManager, authSessionMiddleware }: AgentHttpHandlersDeps,
) { ) {
const inject = createMiddleware<Env>(async (c, next) => { const inject = createMiddleware<Env>(async (c, next) => {
c.set("queryAgent", queryAgent) c.set("sessionManager", sessionManager)
await next() await next()
}) })
@@ -76,11 +76,11 @@ async function handleAgentAsk(c: Context<Env>) {
} }
const user = c.get("user")! const user = c.get("user")!
const queryAgent = c.get("queryAgent") const sessionManager = c.get("sessionManager")
try { try {
const response = await collectQueryAgentResponse(queryAgent, { const session = await sessionManager.getOrCreate(user.id)
userId: user.id, const response = await collectQueryAgentResponse(session.agent, {
message: parsed.message, message: parsed.message,
}) })
return c.json(response) return c.json(response)

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, mock, test } from "bun:test" import { beforeEach, describe, expect, mock, test } from "bun:test"
import type { UserSessionManager } from "../session/index.ts" import type { QueryAgentToolbox } from "./query-agent-toolbox.ts"
import type { QueryAgentEvent } from "./query-agent.ts" import type { QueryAgentEvent } from "./query-agent.ts"
interface FakePiSession { interface FakePiSession {
@@ -11,6 +11,8 @@ interface FakePiSession {
let createAgentSessionCalls = 0 let createAgentSessionCalls = 0
let createAgentSessionOptions: unknown let createAgentSessionOptions: unknown
let runtimeApiKeyCalls: Array<{ provider: string; apiKey: string }> = []
let modelFindCalls: Array<{ provider: string; modelId: string }> = []
let promptCalls = 0 let promptCalls = 0
let unsubscribeCalls = 0 let unsubscribeCalls = 0
let sessionListeners: Array<(event: unknown) => void> = [] let sessionListeners: Array<(event: unknown) => void> = []
@@ -53,7 +55,9 @@ mock.module("@earendil-works/pi-coding-agent", () => ({
AuthStorage: { AuthStorage: {
inMemory() { inMemory() {
return { return {
setRuntimeApiKey(_provider: string, _apiKey: string): void {}, setRuntimeApiKey(provider: string, apiKey: string): void {
runtimeApiKeyCalls.push({ provider, apiKey })
},
} }
}, },
}, },
@@ -73,7 +77,8 @@ mock.module("@earendil-works/pi-coding-agent", () => ({
ModelRegistry: { ModelRegistry: {
inMemory(_authStorage: unknown) { inMemory(_authStorage: unknown) {
return { return {
find(_provider: string, _modelId: string): unknown { find(provider: string, modelId: string): unknown {
modelFindCalls.push({ provider, modelId })
return { id: "mock-model" } return { id: "mock-model" }
}, },
} }
@@ -94,6 +99,8 @@ mock.module("@earendil-works/pi-coding-agent", () => ({
beforeEach(() => { beforeEach(() => {
createAgentSessionCalls = 0 createAgentSessionCalls = 0
createAgentSessionOptions = undefined createAgentSessionOptions = undefined
runtimeApiKeyCalls = []
modelFindCalls = []
promptCalls = 0 promptCalls = 0
unsubscribeCalls = 0 unsubscribeCalls = 0
sessionListeners = [] sessionListeners = []
@@ -124,16 +131,15 @@ describe("PiQueryAgent", () => {
test("rejects a concurrent first query while the Pi session is being created", async () => { test("rejects a concurrent first query while the Pi session is being created", async () => {
const { PiQueryAgent } = await import("./pi-query-agent.ts") const { PiQueryAgent } = await import("./pi-query-agent.ts")
const agent = new PiQueryAgent({ const agent = new PiQueryAgent({
sessionManager: createStubSessionManager(), userId: "user-1",
modelProvider: "mock", toolbox: createStubToolbox(),
modelId: "mock-model", apiKey: "test-api-key",
cwd: "/tmp/freya-pi-query-agent-test", cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test", systemPrompt: "test",
}) })
const firstEvents = collectEvents( const firstEvents = collectEvents(
agent.ask({ agent.ask({
userId: "user-1",
message: "first", message: "first",
}), }),
) )
@@ -142,7 +148,6 @@ describe("PiQueryAgent", () => {
const secondEvents = await collectEvents( const secondEvents = await collectEvents(
agent.ask({ agent.ask({
userId: "user-1",
message: "second", message: "second",
}), }),
) )
@@ -154,6 +159,8 @@ describe("PiQueryAgent", () => {
}, },
]) ])
expect(createAgentSessionCalls).toBe(1) expect(createAgentSessionCalls).toBe(1)
expect(runtimeApiKeyCalls).toEqual([{ provider: "openrouter", apiKey: "test-api-key" }])
expect(modelFindCalls).toEqual([{ provider: "openrouter", modelId: "z-ai/glm-4.7-flash" }])
expect(promptCalls).toBe(0) expect(promptCalls).toBe(0)
releaseSessionCreation() releaseSessionCreation()
@@ -175,9 +182,8 @@ describe("PiQueryAgent", () => {
test("surfaces Pi message_end provider errors instead of done", async () => { test("surfaces Pi message_end provider errors instead of done", async () => {
const { PiQueryAgent } = await import("./pi-query-agent.ts") const { PiQueryAgent } = await import("./pi-query-agent.ts")
const agent = new PiQueryAgent({ const agent = new PiQueryAgent({
sessionManager: createStubSessionManager(), userId: "user-1",
modelProvider: "mock", toolbox: createStubToolbox(),
modelId: "mock-model",
cwd: "/tmp/freya-pi-query-agent-test", cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test", systemPrompt: "test",
}) })
@@ -195,7 +201,6 @@ describe("PiQueryAgent", () => {
const events = collectEvents( const events = collectEvents(
agent.ask({ agent.ask({
userId: "user-1",
message: "hello", message: "hello",
}), }),
) )
@@ -214,9 +219,8 @@ describe("PiQueryAgent", () => {
test("surfaces Pi agent_end provider errors instead of done", async () => { test("surfaces Pi agent_end provider errors instead of done", async () => {
const { PiQueryAgent } = await import("./pi-query-agent.ts") const { PiQueryAgent } = await import("./pi-query-agent.ts")
const agent = new PiQueryAgent({ const agent = new PiQueryAgent({
sessionManager: createStubSessionManager(), userId: "user-1",
modelProvider: "mock", toolbox: createStubToolbox(),
modelId: "mock-model",
cwd: "/tmp/freya-pi-query-agent-test", cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test", systemPrompt: "test",
}) })
@@ -236,7 +240,6 @@ describe("PiQueryAgent", () => {
const events = collectEvents( const events = collectEvents(
agent.ask({ agent.ask({
userId: "user-1",
message: "hello", message: "hello",
}), }),
) )
@@ -261,12 +264,30 @@ async function collectEvents(events: AsyncIterable<QueryAgentEvent>): Promise<Qu
return result return result
} }
function createStubSessionManager(): UserSessionManager { function createStubToolbox(): QueryAgentToolbox {
return { return {
async getOrCreate(): Promise<never> { async listSources(): Promise<never> {
throw new Error("not used") throw new Error("not used")
}, },
} as unknown as UserSessionManager async getContext(): Promise<never> {
throw new Error("not used")
},
async getFeedItem(): Promise<never> {
throw new Error("not used")
},
async queryContext(): Promise<never> {
throw new Error("not used")
},
async listContext(): Promise<never> {
throw new Error("not used")
},
async getSourceData(): Promise<never> {
throw new Error("not used")
},
async executeAction(): Promise<never> {
throw new Error("not used")
},
}
} }
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {

View File

@@ -9,7 +9,7 @@ import {
} from "@earendil-works/pi-coding-agent" } from "@earendil-works/pi-coding-agent"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import type { UserSessionManager } from "../session/index.ts" import type { QueryAgentToolbox } from "./query-agent-toolbox.ts"
import type { 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 { InMemoryResourceLoader } from "./in-memory-resource-loader.ts"
@@ -22,36 +22,37 @@ type PiAgentMessage = PiMessageEndEvent["message"]
type PiAgentEndEvent = Extract<AgentSessionEvent, { type: "agent_end" }> type PiAgentEndEvent = Extract<AgentSessionEvent, { type: "agent_end" }>
export interface PiQueryAgentConfig { export interface PiQueryAgentConfig {
sessionManager: UserSessionManager userId: string
modelProvider: string toolbox: QueryAgentToolbox
modelId: string
apiKey?: string apiKey?: string
cwd?: string cwd?: string
systemPrompt?: string systemPrompt?: string
} }
const MODEL_PROVIDER = "openrouter"
const MODEL_ID = "z-ai/glm-4.7-flash"
export class PiQueryAgent implements QueryAgent { export class PiQueryAgent implements QueryAgent {
private readonly sessionManager: UserSessionManager private readonly userId: string
private readonly toolbox: QueryAgentToolbox
private readonly cwd: string private readonly cwd: string
private readonly systemPrompt: string private readonly systemPrompt: string
private readonly modelProvider: string
private readonly modelId: string
private readonly apiKey: string | undefined private readonly apiKey: string | undefined
private readonly sessions = new Map<string, PiSession>() private session: PiSession | null = null
private readonly pendingSessions = new Map<string, Promise<PiSession>>() private pendingSession: Promise<PiSession> | null = null
private readonly activeRuns = new Map<string, symbol>() private activeRun: symbol | null = null
private disposed = false
constructor(config: PiQueryAgentConfig) { constructor(config: PiQueryAgentConfig) {
this.sessionManager = config.sessionManager this.userId = config.userId
this.modelProvider = config.modelProvider this.toolbox = config.toolbox
this.modelId = config.modelId
this.apiKey = config.apiKey this.apiKey = config.apiKey
this.cwd = config.cwd ?? tmpdir() this.cwd = config.cwd ?? tmpdir()
this.systemPrompt = config.systemPrompt ?? defaultSystemPrompt this.systemPrompt = config.systemPrompt ?? defaultSystemPrompt
} }
async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent> { async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent> {
if (this.activeRuns.has(input.userId)) { if (this.activeRun) {
yield { yield {
type: "error", type: "error",
message: "A query is already running for this user", message: "A query is already running for this user",
@@ -59,14 +60,14 @@ export class PiQueryAgent implements QueryAgent {
return return
} }
const run = Symbol(input.userId) const run = Symbol(this.userId)
this.activeRuns.set(input.userId, run) this.activeRun = run
let session: PiSession let session: PiSession
try { try {
session = await this.getOrCreateSession(input.userId) session = await this.getOrCreateSession()
} catch (err) { } catch (err) {
this.clearActiveRun(input.userId, run) this.clearActiveRun(run)
yield { yield {
type: "error", type: "error",
message: `Failed to create query session: ${errorMessage(err)}`, message: `Failed to create query session: ${errorMessage(err)}`,
@@ -117,7 +118,7 @@ export class PiQueryAgent implements QueryAgent {
}) })
.finally(() => { .finally(() => {
unsubscribe() unsubscribe()
this.clearActiveRun(input.userId, run) this.clearActiveRun(run)
close() close()
}) })
@@ -134,62 +135,62 @@ export class PiQueryAgent implements QueryAgent {
} }
} }
disposeUser(userId: string): void {
const session = this.sessions.get(userId)
session?.dispose()
this.sessions.delete(userId)
this.pendingSessions.delete(userId)
this.activeRuns.delete(userId)
}
dispose(): void { dispose(): void {
for (const session of this.sessions.values()) { this.disposed = true
session.dispose() this.session?.dispose()
} this.session = null
this.sessions.clear() this.pendingSession = null
this.pendingSessions.clear() this.activeRun = null
this.activeRuns.clear()
} }
private clearActiveRun(userId: string, run: symbol): void { private clearActiveRun(run: symbol): void {
if (this.activeRuns.get(userId) === run) { if (this.activeRun === run) {
this.activeRuns.delete(userId) this.activeRun = null
} }
} }
private async getOrCreateSession(userId: string): Promise<PiSession> { private async getOrCreateSession(): Promise<PiSession> {
const existing = this.sessions.get(userId) if (this.disposed) {
if (existing) return existing throw new Error("Query agent is disposed")
}
const pending = this.pendingSessions.get(userId) if (this.session) return this.session
const pending = this.pendingSession
if (pending) return pending if (pending) return pending
const promise = this.createSession(userId) const promise = this.createSession()
this.pendingSessions.set(userId, promise) this.pendingSession = promise
try { try {
const session = await promise const session = await promise
this.sessions.set(userId, session) if (this.disposed) {
session.dispose()
throw new Error("Query agent is disposed")
}
this.session = session
return session return session
} finally { } finally {
this.pendingSessions.delete(userId) if (this.pendingSession === promise) {
this.pendingSession = null
}
} }
} }
private async createSession(userId: string): Promise<PiSession> { private async createSession(): Promise<PiSession> {
const settingsManager = SettingsManager.inMemory({ const settingsManager = SettingsManager.inMemory({
compaction: { enabled: true }, compaction: { enabled: true },
retry: { enabled: true, maxRetries: 2 }, retry: { enabled: true, maxRetries: 2 },
}) })
const authStorage = AuthStorage.inMemory() const authStorage = AuthStorage.inMemory()
if (this.apiKey) { if (this.apiKey) {
authStorage.setRuntimeApiKey(this.modelProvider, this.apiKey) authStorage.setRuntimeApiKey(MODEL_PROVIDER, this.apiKey)
} }
const modelRegistry = ModelRegistry.inMemory(authStorage) const modelRegistry = ModelRegistry.inMemory(authStorage)
const model = modelRegistry.find(this.modelProvider, this.modelId) const model = modelRegistry.find(MODEL_PROVIDER, MODEL_ID)
if (!model) { if (!model) {
throw new Error(`Pi model not found: ${this.modelProvider}/${this.modelId}`) throw new Error(`Pi model not found: ${MODEL_PROVIDER}/${MODEL_ID}`)
} }
const { session } = await createAgentSession({ const { session } = await createAgentSession({
@@ -202,8 +203,7 @@ export class PiQueryAgent implements QueryAgent {
sessionManager: SessionManager.inMemory(this.cwd), sessionManager: SessionManager.inMemory(this.cwd),
noTools: "builtin", noTools: "builtin",
customTools: createFreyaAgentTools({ customTools: createFreyaAgentTools({
userId, toolbox: this.toolbox,
sessionManager: this.sessionManager,
}), }),
tools: [...FREYA_AGENT_TOOL_NAMES], tools: [...FREYA_AGENT_TOOL_NAMES],
}) })

View File

@@ -0,0 +1,93 @@
import type { ContextKeyPart } from "@freya/core"
export interface QueryAgentToolResult {
content: Array<{ type: "text"; text: string }>
details: Record<string, unknown>
}
/**
* Implementation boundary for FREYA query-agent tools.
*
* The Pi-facing tool definitions in `tools.ts` should stay thin: they declare
* schemas, validate and narrow raw model-provided parameters, then delegate to
* this toolbox. Concrete implementations own the actual data gathering,
* source/action lookups, result shaping, and any session-specific behavior.
*/
export interface QueryAgentToolbox {
/**
* Summarizes every source currently visible to the user's session.
*
* Implementations should refresh or read the current feed as needed, then
* return a compact source inventory including feed item counts, context
* entry counts, available action IDs/descriptions, and source errors. This
* is the broad discovery tool an agent can use before deciding which more
* targeted tool call to make.
*/
listSources(): Promise<QueryAgentToolResult>
/**
* Reads context entries from the current FREYA context graph.
*
* `key` is a tuple-style context key. With `match: "exact"`, the implementation
* should return only the value at that exact key and indicate whether it was
* found. With `match: "prefix"`, it should return all entries whose keys
* begin with the provided key parts, plus a count. Implementations may refresh
* the feed first so the context reflects the latest source data.
*/
getContext(key: ContextKeyPart[], match: "exact" | "prefix"): Promise<QueryAgentToolResult>
/**
* Reads one feed item by ID and includes source-local diagnostics.
*
* Implementations should search the current feed for `feedItemId`. When found,
* the result should include the item plus related context entries, source
* action summaries, and source errors. When missing, the result should clearly
* report `found: false` and return `item: null`.
*/
getFeedItem(feedItemId: string): Promise<QueryAgentToolResult>
/**
* Returns the broad context bundle needed to answer a natural-language query.
*
* `question` is included in the result for traceability. If `feedItemId` is
* provided, implementations should also include the matching selected item
* when present. The result should expose the current feed items, context graph
* entries, available source actions, and source errors so the agent can
* synthesize an answer from the user's personal data.
*/
queryContext(question: string, feedItemId?: string): Promise<QueryAgentToolResult>
/**
* Lists every current context graph entry.
*
* This is a lower-level inspection tool than `queryContext`: it should return
* all context entries and a count, without feed items or action summaries.
* Implementations may refresh the feed first to ensure source-provided
* context has been materialized.
*/
listContext(): Promise<QueryAgentToolResult>
/**
* Returns all currently available data for one source.
*
* Implementations should include whether the source is enabled, all feed
* items from `sourceId`, context entries owned by that source, available
* action summaries, and errors from that source. If `feedItemId` is provided,
* the result should also include the matching selected item from that source
* when present.
*/
getSourceData(sourceId: string, feedItemId?: string): Promise<QueryAgentToolResult>
/**
* Executes a source action and returns a serializable execution result.
*
* `sourceId` identifies the source, `actionId` identifies the action within
* that source, and `params` is the source-specific action payload. Tool
* wrappers validate the action envelope, while the source action schema owns
* payload validation. Implementations should let source/action validation
* errors propagate, and on success should return an `ok: true` result plus
* `details.actionExecution` for callers that need a structured record of
* what ran.
*/
executeAction(sourceId: string, actionId: string, params?: unknown): Promise<QueryAgentToolResult>
}

View File

@@ -1,5 +1,4 @@
export interface QueryAgentAsk { export interface QueryAgentAsk {
userId: string
message: string message: string
} }
@@ -12,7 +11,6 @@ export type QueryAgentEvent =
export interface QueryAgent { export interface QueryAgent {
ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent> ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent>
disposeUser(userId: string): void
dispose(): void dispose(): void
} }

View File

@@ -0,0 +1,116 @@
import { describe, expect, mock, test } from "bun:test"
import type { QueryAgentToolResult, QueryAgentToolbox } from "./query-agent-toolbox.ts"
mock.module("@earendil-works/pi-coding-agent", () => ({
defineTool(tool: unknown): unknown {
return tool
},
}))
interface TestTool {
name: string
parameters: unknown
execute(toolCallId: string, params: unknown): Promise<unknown>
}
describe("FREYA agent tools", () => {
test("rejects unknown top-level params", async () => {
const { createFreyaAgentTools, FREYA_GET_CONTEXT_TOOL } = await import("./tools.ts")
const tool = expectTool(
createFreyaAgentTools({ toolbox: createStubToolbox() }),
FREYA_GET_CONTEXT_TOOL,
)
await expect(
tool.execute("tool-call-1", {
key: ["freya.location"],
extra: true,
}),
).rejects.toThrow("extra")
})
test("rejects invalid context keys", async () => {
const { createFreyaAgentTools, FREYA_GET_CONTEXT_TOOL } = await import("./tools.ts")
const tool = expectTool(
createFreyaAgentTools({ toolbox: createStubToolbox() }),
FREYA_GET_CONTEXT_TOOL,
)
await expect(tool.execute("tool-call-1", { key: [] })).rejects.toThrow("key")
await expect(tool.execute("tool-call-1", { key: [["freya.location"]] })).rejects.toThrow("key")
await expect(
tool.execute("tool-call-1", { key: [{ nested: { invalid: true } }] }),
).rejects.toThrow("nested")
})
test("marks tool schemas as closed objects", async () => {
const { createFreyaAgentTools } = await import("./tools.ts")
const tools = createFreyaAgentTools({ toolbox: createStubToolbox() })
for (const tool of tools.map(expectTestTool)) {
expect(expectRecord(tool.parameters).additionalProperties).toBe(false)
}
})
})
function createStubToolbox(): QueryAgentToolbox {
return {
async listSources() {
return toolResult({ sources: [] })
},
async getContext(key, match) {
return toolResult({ key, match })
},
async getFeedItem(feedItemId) {
return toolResult({ feedItemId })
},
async queryContext(question, feedItemId) {
return toolResult({ question, feedItemId })
},
async listContext() {
return toolResult({ entries: [] })
},
async getSourceData(sourceId, feedItemId) {
return toolResult({ sourceId, feedItemId })
},
async executeAction(sourceId, actionId, params) {
return toolResult({ sourceId, actionId, params })
},
}
}
function toolResult(result: unknown): QueryAgentToolResult {
return {
content: [{ type: "text", text: JSON.stringify(result) }],
details: {},
}
}
function expectTool(tools: unknown[], name: string): TestTool {
const tool = tools.map(expectTestTool).find((candidate) => candidate.name === name)
if (!tool) {
throw new Error(`Missing test tool: ${name}`)
}
return tool
}
function expectTestTool(value: unknown): TestTool {
const record = expectRecord(value)
const execute = record.execute
if (typeof record.name !== "string" || typeof execute !== "function") {
throw new Error("Expected test tool")
}
return {
name: record.name,
parameters: record.parameters,
execute: execute as TestTool["execute"],
}
}
function expectRecord(value: unknown): Record<string, unknown> {
expect(typeof value).toBe("object")
expect(value).not.toBeNull()
expect(Array.isArray(value)).toBe(false)
return value as Record<string, unknown>
}

View File

@@ -1,14 +1,11 @@
import { defineTool } from "@earendil-works/pi-coding-agent" import { defineTool } from "@earendil-works/pi-coding-agent"
import { type } from "arktype"
import { Type } from "typebox" import { Type } from "typebox"
import type { UserSessionManager } from "../session/index.ts" import type { QueryAgentToolbox } from "./query-agent-toolbox.ts"
import type { QueryDebugTools } from "./debug-tools.ts"
import { createQueryDebugTools } from "./debug-tools.ts"
interface CreateFreyaAgentToolsConfig { interface CreateFreyaAgentToolsConfig {
userId: string toolbox: QueryAgentToolbox
sessionManager: UserSessionManager
} }
export const FREYA_QUERY_CONTEXT_TOOL = "freya_query_context" export const FREYA_QUERY_CONTEXT_TOOL = "freya_query_context"
@@ -19,6 +16,41 @@ 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_GET_FEED_ITEM_TOOL = "freya_get_feed_item"
export const FREYA_EXECUTE_ACTION_TOOL = "freya_execute_action" export const FREYA_EXECUTE_ACTION_TOOL = "freya_execute_action"
const ContextKeyObjectPart = type("Record<string, string | number | boolean>").narrow(
(value) => !Array.isArray(value),
)
const ContextKeyPart = type("string | number").or(ContextKeyObjectPart)
const GetContextToolParams = type({
"+": "reject",
key: ContextKeyPart.array().atLeastLength(1),
"match?": "'exact' | 'prefix'",
})
const GetFeedItemToolParams = type({
"+": "reject",
feedItemId: type.string.atLeastLength(1),
})
const QueryContextToolParams = type({
"+": "reject",
question: type.string.atLeastLength(1),
"feedItemId?": "string",
})
const GetSourceDataToolParams = type({
"+": "reject",
sourceId: type.string.atLeastLength(1),
"feedItemId?": "string",
})
const ExecuteActionToolParams = type({
"+": "reject",
sourceId: type.string.atLeastLength(1),
actionId: type.string.atLeastLength(1),
"params?": "unknown",
})
export const FREYA_AGENT_TOOL_NAMES = [ export const FREYA_AGENT_TOOL_NAMES = [
FREYA_LIST_SOURCES_TOOL, FREYA_LIST_SOURCES_TOOL,
FREYA_GET_CONTEXT_TOOL, FREYA_GET_CONTEXT_TOOL,
@@ -30,16 +62,13 @@ export const FREYA_AGENT_TOOL_NAMES = [
] ]
export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) { export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
const { userId } = config
const debugTools = createQueryDebugTools(config.sessionManager)
const listSourcesTool = defineTool({ const listSourcesTool = defineTool({
name: FREYA_LIST_SOURCES_TOOL, name: FREYA_LIST_SOURCES_TOOL,
label: "List FREYA Sources", label: "List FREYA Sources",
description: description:
"List enabled FREYA source IDs and summarize available feed items, context entries, actions, and errors.", "List enabled FREYA source IDs and summarize available feed items, context entries, actions, and errors.",
parameters: Type.Object({}), parameters: Type.Object({}, { additionalProperties: false }),
execute: async () => executeDebugTool(debugTools, userId, FREYA_LIST_SOURCES_TOOL, {}), execute: async () => executeListSourcesTool(config.toolbox),
}) })
const getContextTool = defineTool({ const getContextTool = defineTool({
@@ -47,30 +76,34 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
label: "Get FREYA Context", label: "Get FREYA Context",
description: description:
"Read specific FREYA context entries by key. Use prefix matching to discover entries under a source ID, or exact matching when you know the full key.", "Read specific FREYA context entries by key. Use prefix matching to discover entries under a source ID, or exact matching when you know the full key.",
parameters: Type.Object({ parameters: Type.Object(
key: Type.Array(Type.Unknown(), { {
description: key: Type.Array(Type.Unknown(), {
'Context key array, for example ["freya.location"] or ["freya.location", "location"].', description:
}), 'Context key array, for example ["freya.location"] or ["freya.location", "location"].',
match: Type.Optional(
Type.Union([Type.Literal("exact"), Type.Literal("prefix")], {
description: "Match mode. Defaults to prefix.",
}), }),
), match: Type.Optional(
}), Type.Union([Type.Literal("exact"), Type.Literal("prefix")], {
execute: async (_toolCallId, params) => description: "Match mode. Defaults to prefix.",
executeDebugTool(debugTools, userId, FREYA_GET_CONTEXT_TOOL, params), }),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, params) => executeGetContextTool(config.toolbox, params),
}) })
const getFeedItemTool = defineTool({ const getFeedItemTool = defineTool({
name: FREYA_GET_FEED_ITEM_TOOL, name: FREYA_GET_FEED_ITEM_TOOL,
label: "Get FREYA Feed Item", label: "Get FREYA Feed Item",
description: "Read one feed item by ID, including related source context, actions, and errors.", description: "Read one feed item by ID, including related source context, actions, and errors.",
parameters: Type.Object({ parameters: Type.Object(
feedItemId: Type.String({ description: "Feed item ID to inspect." }), {
}), feedItemId: Type.String({ description: "Feed item ID to inspect." }),
execute: async (_toolCallId, params) => },
executeDebugTool(debugTools, userId, FREYA_GET_FEED_ITEM_TOOL, params), { additionalProperties: false },
),
execute: async (_toolCallId, params) => executeGetFeedItemTool(config.toolbox, params),
}) })
const queryContextTool = defineTool({ const queryContextTool = defineTool({
@@ -78,17 +111,20 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
label: "Query FREYA Context", label: "Query FREYA Context",
description: description:
"Read the user's current FREYA feed, source graph context, source errors, and available actions.", "Read the user's current FREYA feed, source graph context, source errors, and available actions.",
parameters: Type.Object({ parameters: Type.Object(
question: Type.String({ {
description: "The specific personal-context question to answer.", question: Type.String({
}), description: "The specific personal-context question to answer.",
feedItemId: Type.Optional(
Type.String({
description: "Optional feed item ID when the user is asking about a specific card.",
}), }),
), feedItemId: Type.Optional(
}), Type.String({
execute: async (_toolCallId, params) => executeQueryContextTool(config, params), description: "Optional feed item ID when the user is asking about a specific card.",
}),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, params) => executeQueryContextTool(config.toolbox, params),
}) })
const listContextTool = defineTool({ const listContextTool = defineTool({
@@ -96,8 +132,8 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
label: "List FREYA Context", label: "List FREYA Context",
description: description:
"List all current FREYA context graph entries for the user. Use this to inspect what personal context is available.", "List all current FREYA context graph entries for the user. Use this to inspect what personal context is available.",
parameters: Type.Object({}), parameters: Type.Object({}, { additionalProperties: false }),
execute: async () => executeListContextTool(config), execute: async () => executeListContextTool(config.toolbox),
}) })
const getSourceDataTool = defineTool({ const getSourceDataTool = defineTool({
@@ -105,17 +141,20 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
label: "Get FREYA Source Data", label: "Get FREYA Source Data",
description: description:
"Get current feed items, context entries, actions, and errors for a specific FREYA source ID.", "Get current feed items, context entries, actions, and errors for a specific FREYA source ID.",
parameters: Type.Object({ parameters: Type.Object(
sourceId: Type.String({ {
description: "Source ID, for example freya.location, freya.tfl, or freya.weather.", sourceId: Type.String({
}), description: "Source ID, for example freya.location, freya.tfl, or freya.weather.",
feedItemId: Type.Optional(
Type.String({
description: "Optional feed item ID to select one item from the source.",
}), }),
), feedItemId: Type.Optional(
}), Type.String({
execute: async (_toolCallId, params) => executeGetSourceDataTool(config, params), description: "Optional feed item ID to select one item from the source.",
}),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, params) => executeGetSourceDataTool(config.toolbox, params),
}) })
const executeActionTool = defineTool({ const executeActionTool = defineTool({
@@ -123,16 +162,19 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
label: "Execute FREYA Action", label: "Execute FREYA Action",
description: description:
"Execute an available FREYA source action immediately without creating a proposal.", "Execute an available FREYA source action immediately without creating a proposal.",
parameters: Type.Object({ parameters: Type.Object(
sourceId: Type.String({ description: "Source ID that should execute the action." }), {
actionId: Type.String({ description: "Source action ID to execute." }), sourceId: Type.String({ description: "Source ID that should execute the action." }),
params: Type.Optional( actionId: Type.String({ description: "Source action ID to execute." }),
Type.Unknown({ params: Type.Optional(
description: "Parameters to pass to the source action.", Type.Unknown({
}), description: "Parameters to pass to the source action.",
), }),
}), ),
execute: async (_toolCallId, params) => executeActionToolCall(config, params), },
{ additionalProperties: false },
),
execute: async (_toolCallId, params) => executeActionToolCall(config.toolbox, params),
}) })
return [ return [
@@ -146,166 +188,57 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
] ]
} }
async function executeDebugTool( async function executeListSourcesTool(toolbox: QueryAgentToolbox) {
debugTools: QueryDebugTools, return toolbox.listSources()
userId: string,
toolName: string,
params: unknown,
) {
const result = await debugTools.execute(userId, toolName, params)
return {
content: [
{
type: "text" as const,
text: JSON.stringify(result),
},
],
details: {},
}
} }
async function executeQueryContextTool( async function executeGetContextTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
config: CreateFreyaAgentToolsConfig, const params = GetContextToolParams(rawParams)
params: { question: string; feedItemId?: string }, if (params instanceof type.errors) {
) { throw new Error(params.summary)
const userSession = await config.sessionManager.getOrCreate(config.userId)
const feed = await userSession.feed()
const context = userSession.engine.currentContext()
const feedItemId = params.feedItemId
const selectedItem =
typeof feedItemId === "string" ? feed.items.find((item) => item.id === feedItemId) : undefined
const actions = await userSession.listActions()
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
time: context.time.toISOString(),
question: params.question,
feedItemId: feedItemId ?? null,
selectedItem: selectedItem ?? null,
items: feed.items,
context: context.entries(),
availableActions: actions.map((entry) => ({
sourceId: entry.sourceId,
actions: Object.values(entry.actions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
})),
errors: feed.errors.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
})),
}),
},
],
details: {},
} }
const match = params.match ?? "prefix"
return toolbox.getContext(params.key, match)
} }
async function executeListContextTool(config: CreateFreyaAgentToolsConfig) { async function executeGetFeedItemTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
const userSession = await config.sessionManager.getOrCreate(config.userId) const params = GetFeedItemToolParams(rawParams)
await userSession.feed() if (params instanceof type.errors) {
const context = userSession.engine.currentContext() throw new Error(params.summary)
const entries = context.entries()
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
time: context.time.toISOString(),
count: entries.length,
entries,
}),
},
],
details: {},
} }
return toolbox.getFeedItem(params.feedItemId)
} }
async function executeGetSourceDataTool( async function executeQueryContextTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
config: CreateFreyaAgentToolsConfig, const params = QueryContextToolParams(rawParams)
params: { sourceId: string; feedItemId?: string }, if (params instanceof type.errors) {
) { throw new Error(params.summary)
const userSession = await config.sessionManager.getOrCreate(config.userId)
const feed = await userSession.feed()
const context = userSession.engine.currentContext()
const sourceActions = userSession.hasSource(params.sourceId)
? await userSession.engine.listActions(params.sourceId)
: {}
const items = feed.items.filter((item) => item.sourceId === params.sourceId)
const selectedItem =
params.feedItemId !== undefined
? items.find((item) => item.id === params.feedItemId)
: undefined
const contextEntries = context.entries().filter((entry) => entry.key[0] === params.sourceId)
const errors = feed.errors
.filter((error) => error.sourceId === params.sourceId)
.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
}))
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
time: context.time.toISOString(),
sourceId: params.sourceId,
hasSource: userSession.hasSource(params.sourceId),
feedItemId: params.feedItemId ?? null,
selectedItem: selectedItem ?? null,
items,
context: contextEntries,
actions: Object.values(sourceActions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
errors,
}),
},
],
details: {},
} }
return toolbox.queryContext(params.question, params.feedItemId)
} }
async function executeActionToolCall( async function executeListContextTool(toolbox: QueryAgentToolbox) {
config: CreateFreyaAgentToolsConfig, return toolbox.listContext()
params: { }
sourceId: string
actionId: string async function executeGetSourceDataTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
params?: unknown const params = GetSourceDataToolParams(rawParams)
}, if (params instanceof type.errors) {
) { throw new Error(params.summary)
const userSession = await config.sessionManager.getOrCreate(config.userId) }
const result = await userSession.engine.executeAction(
params.sourceId, return toolbox.getSourceData(params.sourceId, params.feedItemId)
params.actionId, }
params.params,
) async function executeActionToolCall(toolbox: QueryAgentToolbox, rawParams: unknown) {
const params = ExecuteActionToolParams(rawParams)
const actionExecution = { if (params instanceof type.errors) {
sourceId: params.sourceId, throw new Error(params.summary)
actionId: params.actionId, }
result: result ?? null,
} return toolbox.executeAction(params.sourceId, params.actionId, params.params)
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
ok: true,
...actionExecution,
}),
},
],
details: { actionExecution },
}
} }

View File

@@ -0,0 +1,253 @@
import { contextKey, type ContextKeyPart } from "@freya/core"
import type { UserSession } from "../session/user-session.ts"
import type { QueryAgentToolResult, QueryAgentToolbox } from "./query-agent-toolbox.ts"
export class UserSessionQueryAgentToolbox implements QueryAgentToolbox {
constructor(private readonly session: UserSession) {}
async listSources(): Promise<QueryAgentToolResult> {
const feed = await this.session.feed()
const context = this.session.engine.currentContext()
const contextEntries = context.entries()
const actions = await this.session.listActions()
const feedCounts = countBy(feed.items.map((item) => item.sourceId))
const contextCounts = countBy(
contextEntries
.map((entry) => entry.key[0])
.filter((part): part is string => typeof part === "string"),
)
const errors = groupErrorsBySource(
feed.errors.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
})),
)
const actionEntries = new Map(actions.map((entry) => [entry.sourceId, entry.actions]))
const sourceIds = new Set<string>([
...actionEntries.keys(),
...feedCounts.keys(),
...contextCounts.keys(),
...errors.keys(),
])
return toolResult({
time: context.time.toISOString(),
sources: [...sourceIds].sort().map((sourceId) => {
const sourceActions = actionEntries.get(sourceId) ?? {}
const feedItemCount = feedCounts.get(sourceId) ?? 0
const contextEntryCount = contextCounts.get(sourceId) ?? 0
return {
sourceId,
hasFeedItems: feedItemCount > 0,
feedItemCount,
hasContext: contextEntryCount > 0,
contextEntryCount,
actions: Object.values(sourceActions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
errors: errors.get(sourceId) ?? [],
}
}),
})
}
async getContext(
key: ContextKeyPart[],
match: "exact" | "prefix",
): Promise<QueryAgentToolResult> {
await this.session.feed()
const context = this.session.engine.currentContext()
const keyObject = contextKey(...key)
if (match === "exact") {
const value = context.get(keyObject)
return toolResult({
time: context.time.toISOString(),
match,
key,
found: value !== undefined,
value: value ?? null,
})
}
const entries = context.find(keyObject)
return toolResult({
time: context.time.toISOString(),
match,
key,
count: entries.length,
entries,
})
}
async getFeedItem(feedItemId: string): Promise<QueryAgentToolResult> {
const feed = await this.session.feed()
const context = this.session.engine.currentContext()
const item = feed.items.find((candidate) => candidate.id === feedItemId)
if (!item) {
return toolResult({
time: context.time.toISOString(),
feedItemId,
found: false,
item: null,
})
}
const sourceActions = this.session.hasSource(item.sourceId)
? await this.session.engine.listActions(item.sourceId)
: {}
const errors = feed.errors
.filter((error) => error.sourceId === item.sourceId)
.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
}))
return toolResult({
time: context.time.toISOString(),
feedItemId,
found: true,
item,
source: {
sourceId: item.sourceId,
hasSource: this.session.hasSource(item.sourceId),
context: context.entries().filter((entry) => entry.key[0] === item.sourceId),
actions: Object.values(sourceActions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
errors,
},
})
}
async queryContext(question: string, feedItemId?: string): Promise<QueryAgentToolResult> {
const feed = await this.session.feed()
const context = this.session.engine.currentContext()
const selectedItem = feedItemId ? feed.items.find((item) => item.id === feedItemId) : undefined
const actions = await this.session.listActions()
return toolResult({
time: context.time.toISOString(),
question,
feedItemId: feedItemId ?? null,
selectedItem: selectedItem ?? null,
items: feed.items,
context: context.entries(),
availableActions: actions.map((entry) => ({
sourceId: entry.sourceId,
actions: Object.values(entry.actions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
})),
errors: feed.errors.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
})),
})
}
async listContext(): Promise<QueryAgentToolResult> {
await this.session.feed()
const context = this.session.engine.currentContext()
const entries = context.entries()
return toolResult({
time: context.time.toISOString(),
count: entries.length,
entries,
})
}
async getSourceData(sourceId: string, feedItemId?: string): Promise<QueryAgentToolResult> {
const feed = await this.session.feed()
const context = this.session.engine.currentContext()
const sourceActions = this.session.hasSource(sourceId)
? await this.session.engine.listActions(sourceId)
: {}
const items = feed.items.filter((item) => item.sourceId === sourceId)
const selectedItem = feedItemId ? items.find((item) => item.id === feedItemId) : undefined
const contextEntries = context.entries().filter((entry) => entry.key[0] === sourceId)
const errors = feed.errors
.filter((error) => error.sourceId === sourceId)
.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
}))
return toolResult({
time: context.time.toISOString(),
sourceId,
hasSource: this.session.hasSource(sourceId),
feedItemId: feedItemId ?? null,
selectedItem: selectedItem ?? null,
items,
context: contextEntries,
actions: Object.values(sourceActions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
errors,
})
}
async executeAction(
sourceId: string,
actionId: string,
params?: unknown,
): Promise<QueryAgentToolResult> {
const result = await this.session.engine.executeAction(sourceId, actionId, params)
const actionExecution = {
sourceId,
actionId,
result: result ?? null,
}
return toolResult(
{
ok: true,
...actionExecution,
},
{ actionExecution },
)
}
}
function toolResult(result: unknown, details: Record<string, unknown> = {}): QueryAgentToolResult {
return {
content: [
{
type: "text" as const,
text: JSON.stringify(result),
},
],
details,
}
}
function countBy(values: string[]): Map<string, number> {
const result = new Map<string, number>()
for (const value of values) {
result.set(value, (result.get(value) ?? 0) + 1)
}
return result
}
function groupErrorsBySource(
errors: Array<{ sourceId: string; message: string }>,
): Map<string, Array<{ sourceId: string; message: string }>> {
const result = new Map<string, Array<{ sourceId: string; message: string }>>()
for (const error of errors) {
const group = result.get(error.sourceId) ?? []
group.push(error)
result.set(error.sourceId, group)
}
return result
}

View File

@@ -11,7 +11,6 @@ describe("ensureEnv", () => {
EXA_API_KEY: " exa-key ", EXA_API_KEY: " exa-key ",
GOOGLE_MAPS_API_KEY: " google-maps-key ", GOOGLE_MAPS_API_KEY: " google-maps-key ",
OPENROUTER_API_KEY: " openrouter-key ", OPENROUTER_API_KEY: " openrouter-key ",
OPENROUTER_MODEL: " model-name ",
TFL_API_KEY: " tfl-key ", TFL_API_KEY: " tfl-key ",
WEATHERKIT_KEY_ID: " weather-key-id ", WEATHERKIT_KEY_ID: " weather-key-id ",
WEATHERKIT_PRIVATE_KEY: " weather-private-key ", WEATHERKIT_PRIVATE_KEY: " weather-private-key ",
@@ -26,7 +25,6 @@ describe("ensureEnv", () => {
exaApiKey: "exa-key", exaApiKey: "exa-key",
googleMapsApiKey: "google-maps-key", googleMapsApiKey: "google-maps-key",
openrouterApiKey: "openrouter-key", openrouterApiKey: "openrouter-key",
openrouterModel: "model-name",
tflApiKey: "tfl-key", tflApiKey: "tfl-key",
weatherkitKeyId: "weather-key-id", weatherkitKeyId: "weather-key-id",
weatherkitPrivateKey: "weather-private-key", weatherkitPrivateKey: "weather-private-key",
@@ -53,25 +51,6 @@ describe("ensureEnv", () => {
).toThrow("Missing required environment variables: GOOGLE_MAPS_API_KEY") ).toThrow("Missing required environment variables: GOOGLE_MAPS_API_KEY")
}) })
test("allows openrouter model to be omitted", () => {
const env = ensureEnv({
BETTER_AUTH_SECRET: "auth-secret",
CREDENTIAL_ENCRYPTION_KEY: "credential-key",
DATABASE_URL: "postgres://example",
EXA_API_KEY: "exa-key",
GOOGLE_MAPS_API_KEY: "google-maps-key",
OPENROUTER_API_KEY: "openrouter-key",
TFL_API_KEY: "tfl-key",
WEATHERKIT_KEY_ID: "weather-key-id",
WEATHERKIT_PRIVATE_KEY: "weather-private-key",
WEATHERKIT_SERVICE_ID: "weather-service-id",
WEATHERKIT_TEAM_ID: "weather-team-id",
})
expect(env.googleMapsApiKey).toBe("google-maps-key")
expect(env.openrouterModel).toBeUndefined()
})
test("throws with all missing required env names", () => { test("throws with all missing required env names", () => {
expect(() => ensureEnv({})).toThrow( expect(() => ensureEnv({})).toThrow(
"Missing required environment variables: BETTER_AUTH_SECRET, CREDENTIAL_ENCRYPTION_KEY, DATABASE_URL, EXA_API_KEY, OPENROUTER_API_KEY, TFL_API_KEY, WEATHERKIT_PRIVATE_KEY, WEATHERKIT_KEY_ID, WEATHERKIT_TEAM_ID, WEATHERKIT_SERVICE_ID, GOOGLE_MAPS_API_KEY", "Missing required environment variables: BETTER_AUTH_SECRET, CREDENTIAL_ENCRYPTION_KEY, DATABASE_URL, EXA_API_KEY, OPENROUTER_API_KEY, TFL_API_KEY, WEATHERKIT_PRIVATE_KEY, WEATHERKIT_KEY_ID, WEATHERKIT_TEAM_ID, WEATHERKIT_SERVICE_ID, GOOGLE_MAPS_API_KEY",

View File

@@ -5,7 +5,6 @@ export interface ServerEnv {
exaApiKey: string exaApiKey: string
googleMapsApiKey: string googleMapsApiKey: string
openrouterApiKey: string openrouterApiKey: string
openrouterModel: string | undefined
tflApiKey: string tflApiKey: string
weatherkitKeyId: string weatherkitKeyId: string
weatherkitPrivateKey: string weatherkitPrivateKey: string
@@ -39,7 +38,6 @@ export function ensureEnv(env: Record<string, string | undefined>): ServerEnv {
exaApiKey, exaApiKey,
googleMapsApiKey, googleMapsApiKey,
openrouterApiKey, openrouterApiKey,
openrouterModel: readOptionalEnv(env, "OPENROUTER_MODEL"),
tflApiKey, tflApiKey,
weatherkitKeyId, weatherkitKeyId,
weatherkitPrivateKey, weatherkitPrivateKey,

View File

@@ -4,7 +4,6 @@ import { cors } from "hono/cors"
import { registerAdminHttpHandlers } from "./admin/http.ts" import { registerAdminHttpHandlers } from "./admin/http.ts"
import { createQueryDebugTools } from "./agent/debug-tools.ts" import { createQueryDebugTools } from "./agent/debug-tools.ts"
import { registerAgentHttpHandlers, registerDebugAgentHttpHandlers } from "./agent/http.ts" import { registerAgentHttpHandlers, registerDebugAgentHttpHandlers } from "./agent/http.ts"
import { PiQueryAgent } from "./agent/pi-query-agent.ts"
import { createRequireAdmin } from "./auth/admin-middleware.ts" import { createRequireAdmin } from "./auth/admin-middleware.ts"
import { registerAuthHandlers } from "./auth/http.ts" import { registerAuthHandlers } from "./auth/http.ts"
import { createAuth } from "./auth/index.ts" import { createAuth } from "./auth/index.ts"
@@ -35,11 +34,11 @@ function main() {
const feedEnhancer = createFeedEnhancer({ const feedEnhancer = createFeedEnhancer({
client: createLlmClient({ client: createLlmClient({
apiKey: env.openrouterApiKey, apiKey: env.openrouterApiKey,
model: env.openrouterModel,
}), }),
}) })
const credentialEncryptor = new CredentialEncryptor(env.credentialEncryptionKey) const credentialEncryptor = new CredentialEncryptor(env.credentialEncryptionKey)
const piApiKey = process.env.PI_API_KEY ?? env.openrouterApiKey
const sessionManager = new UserSessionManager({ const sessionManager = new UserSessionManager({
db, db,
@@ -63,13 +62,9 @@ function main() {
], ],
feedEnhancer, feedEnhancer,
credentialEncryptor, credentialEncryptor,
}) queryAgent: {
const piApiKey = process.env.PI_API_KEY ?? env.openrouterApiKey apiKey: piApiKey,
const queryAgent = new PiQueryAgent({ },
sessionManager,
modelProvider: process.env.PI_MODEL_PROVIDER ?? "openrouter",
modelId: process.env.PI_MODEL ?? env.openrouterModel ?? "z-ai/glm-4.7-flash",
apiKey: piApiKey,
}) })
if (!piApiKey) { if (!piApiKey) {
console.warn("[query] PI_API_KEY or OPENROUTER_API_KEY not set — query agent unavailable") console.warn("[query] PI_API_KEY or OPENROUTER_API_KEY not set — query agent unavailable")
@@ -120,7 +115,7 @@ function main() {
registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware }) registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware })
registerSourcesHttpHandlers(app, { sessionManager, authSessionMiddleware }) registerSourcesHttpHandlers(app, { sessionManager, authSessionMiddleware })
registerAgentHttpHandlers(app, { registerAgentHttpHandlers(app, {
queryAgent, sessionManager,
authSessionMiddleware, authSessionMiddleware,
}) })
if (isDebugMode) { if (isDebugMode) {
@@ -133,7 +128,7 @@ function main() {
registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db }) registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db })
process.on("SIGTERM", async () => { process.on("SIGTERM", async () => {
queryAgent.dispose() sessionManager.dispose()
await closeDb() await closeDb()
process.exit(0) process.exit(0)
}) })

View File

@@ -1,7 +1,6 @@
import type { FeedSource } from "@freya/core" import type { FeedSource } from "@freya/core"
import type { type } from "arktype"
export type ConfigSchema = ReturnType<typeof type> export type ConfigSchema = (value: unknown) => unknown
export interface FeedSourceProvider { export interface FeedSourceProvider {
/** The source ID this provider is responsible for (e.g., "freya.location"). */ /** The source ID this provider is responsible for (e.g., "freya.location"). */

View File

@@ -14,13 +14,14 @@ import {
SourceNotFoundError, SourceNotFoundError,
} from "../sources/errors.ts" } from "../sources/errors.ts"
import { sources } from "../sources/user-sources.ts" import { sources } from "../sources/user-sources.ts"
import { UserSession } from "./user-session.ts" import { UserSession, type UserSessionAgentConfig } from "./user-session.ts"
export interface UserSessionManagerConfig { export interface UserSessionManagerConfig {
db: Database db: Database
providers: FeedSourceProvider[] providers: FeedSourceProvider[]
feedEnhancer?: FeedEnhancer | null feedEnhancer?: FeedEnhancer | null
credentialEncryptor?: CredentialEncryptor | null credentialEncryptor?: CredentialEncryptor | null
queryAgent?: UserSessionAgentConfig
} }
export class UserSessionManager { export class UserSessionManager {
@@ -30,6 +31,7 @@ export class UserSessionManager {
private readonly providers = new Map<string, FeedSourceProvider>() private readonly providers = new Map<string, FeedSourceProvider>()
private readonly feedEnhancer: FeedEnhancer | null private readonly feedEnhancer: FeedEnhancer | null
private readonly encryptor: CredentialEncryptor | null private readonly encryptor: CredentialEncryptor | null
private readonly queryAgentConfig: UserSessionAgentConfig | undefined
constructor(config: UserSessionManagerConfig) { constructor(config: UserSessionManagerConfig) {
this.db = config.db this.db = config.db
@@ -38,6 +40,7 @@ export class UserSessionManager {
} }
this.feedEnhancer = config.feedEnhancer ?? null this.feedEnhancer = config.feedEnhancer ?? null
this.encryptor = config.credentialEncryptor ?? null this.encryptor = config.credentialEncryptor ?? null
this.queryAgentConfig = config.queryAgent
} }
getProvider(sourceId: string): FeedSourceProvider | undefined { getProvider(sourceId: string): FeedSourceProvider | undefined {
@@ -99,6 +102,14 @@ export class UserSessionManager {
this.pending.delete(userId) this.pending.delete(userId)
} }
dispose(): void {
for (const session of this.sessions.values()) {
session.destroy()
}
this.sessions.clear()
this.pending.clear()
}
/** /**
* Merges, validates, and persists a user's source config and/or enabled * Merges, validates, and persists a user's source config and/or enabled
* state, then invalidates the cached session. * state, then invalidates the cached session.
@@ -362,7 +373,7 @@ export class UserSessionManager {
} }
if (promises.length === 0) { if (promises.length === 0) {
return new UserSession(userId, [], this.feedEnhancer) return new UserSession(userId, [], this.feedEnhancer, this.queryAgentConfig)
} }
const results = await Promise.allSettled(promises) const results = await Promise.allSettled(promises)
@@ -386,7 +397,7 @@ export class UserSessionManager {
console.error("[UserSessionManager] Feed source provider failed:", error) console.error("[UserSessionManager] Feed source provider failed:", error)
} }
return new UserSession(userId, feedSources, this.feedEnhancer) return new UserSession(userId, feedSources, this.feedEnhancer, this.queryAgentConfig)
} }
/** /**

View File

@@ -58,6 +58,15 @@ describe("UserSession", () => {
expect(session.getSource("test")).toBeUndefined() expect(session.getSource("test")).toBeUndefined()
}) })
test("destroy disposes query agent", () => {
const session = new UserSession("test-user", [createStubSource("test")])
const disposeSpy = spyOn(session.agent, "dispose")
session.destroy()
expect(disposeSpy).toHaveBeenCalled()
})
test("engine.executeAction routes to correct source", async () => { test("engine.executeAction routes to correct source", async () => {
const location = new LocationSource() const location = new LocationSource()
const session = new UserSession("test-user", [location]) const session = new UserSession("test-user", [location])

View File

@@ -6,11 +6,24 @@ import {
type FeedSource, type FeedSource,
} from "@freya/core" } from "@freya/core"
import type { QueryAgentToolbox } from "../agent/query-agent-toolbox.ts"
import type { QueryAgent } from "../agent/query-agent.ts"
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts" import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import { PiQueryAgent } from "../agent/pi-query-agent.ts"
import { UserSessionQueryAgentToolbox } from "../agent/user-session-query-agent-toolbox.ts"
export interface UserSessionAgentConfig {
apiKey?: string
cwd?: string
systemPrompt?: string
}
export class UserSession { export class UserSession {
readonly userId: string readonly userId: string
readonly engine: FeedEngine readonly engine: FeedEngine
readonly toolbox: QueryAgentToolbox
readonly agent: QueryAgent
private sources = new Map<string, FeedSource>() private sources = new Map<string, FeedSource>()
private readonly enhancer: FeedEnhancer | null private readonly enhancer: FeedEnhancer | null
private enhancedItems: FeedItem[] | null = null private enhancedItems: FeedItem[] | null = null
@@ -19,7 +32,12 @@ export class UserSession {
private enhancingPromise: Promise<void> | null = null private enhancingPromise: Promise<void> | null = null
private unsubscribe: (() => void) | null = null private unsubscribe: (() => void) | null = null
constructor(userId: string, sources: FeedSource[], enhancer?: FeedEnhancer | null) { constructor(
userId: string,
sources: FeedSource[],
enhancer?: FeedEnhancer | null,
agentConfig?: UserSessionAgentConfig,
) {
this.userId = userId this.userId = userId
this.engine = new FeedEngine() this.engine = new FeedEngine()
this.enhancer = enhancer ?? null this.enhancer = enhancer ?? null
@@ -35,6 +53,15 @@ export class UserSession {
}) })
} }
this.toolbox = new UserSessionQueryAgentToolbox(this)
this.agent = new PiQueryAgent({
userId: this.userId,
toolbox: this.toolbox,
apiKey: agentConfig?.apiKey,
cwd: agentConfig?.cwd,
systemPrompt: agentConfig?.systemPrompt,
})
this.engine.start() this.engine.start()
} }
@@ -174,6 +201,7 @@ export class UserSession {
} }
destroy(): void { destroy(): void {
this.agent.dispose()
this.unsubscribe?.() this.unsubscribe?.()
this.unsubscribe = null this.unsubscribe = null
this.engine.stop() this.engine.stop()

View File

@@ -37,13 +37,13 @@ export function meta({}: Route.MetaArgs) {
}, },
{ property: "og:title", content: PAGE_TITLE }, { property: "og:title", content: PAGE_TITLE },
{ property: "og:description", content: PAGE_DESCRIPTION }, { property: "og:description", content: PAGE_DESCRIPTION },
{ property: "og:image", content: "https://freya.chat/social-media-preview.jpg" }, { property: "og:image", content: "https://ael.is/social-media-preview.png" },
{ property: "og:url", content: "https://freya.chat" }, { property: "og:url", content: "https://ael.is" },
{ property: "og:type", content: "website" }, { property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary_large_image" }, { name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: PAGE_TITLE }, { name: "twitter:title", content: PAGE_TITLE },
{ name: "twitter:description", content: PAGE_DESCRIPTION }, { name: "twitter:description", content: PAGE_DESCRIPTION },
{ name: "twitter:image", content: "https://freya.chat/social-media-preview.jpg" }, { name: "twitter:image", content: "https://ael.is/social-media-preview.png" },
] ]
} }
@@ -84,7 +84,7 @@ export async function action({ request }: Route.ActionArgs) {
await new Promise((resolve) => setTimeout(resolve, 1000)) await new Promise((resolve) => setTimeout(resolve, 1000))
const emailRes = await resend.emails.send({ const emailRes = await resend.emails.send({
from: "Freya <no-reply@freya.chat>", from: "Freya <no-reply@ael.is>",
to: email, to: email,
template: { template: {
id: "waitlist-confirmation", id: "waitlist-confirmation",
@@ -380,6 +380,7 @@ function SystemMessageBubble({
isAnimating={isStreaming} isAnimating={isStreaming}
linkSafety={{ enabled: false }} linkSafety={{ enabled: false }}
components={{ components={{
// @ts-expect-error
a: ({ className, ...props }) => <a className={`underline ${className}`} {...props} />, a: ({ className, ...props }) => <a className={`underline ${className}`} {...props} />,
}} }}
> >

View File

@@ -40,7 +40,7 @@ const POLICY = `# Privacy Policy
**Last updated:** March 5, 2026 **Last updated:** March 5, 2026
This Privacy Policy describes how **Freya** ("we", "us", or "our") collects, uses, and protects your personal information when you visit **https://freya.chat** or interact with our services. This Privacy Policy describes how **Freya** ("we", "us", or "our") collects, uses, and protects your personal information when you visit **https://ael.is** or interact with our services.
If you do not agree with this Privacy Policy, please do not use the website. If you do not agree with this Privacy Policy, please do not use the website.

View File

@@ -1,4 +1,4 @@
User-agent: * User-agent: *
Allow: / Allow: /
Sitemap: https://freya.chat/sitemap.xml Sitemap: https://ael.is/sitemap.xml

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url> <url>
<loc>https://freya.chat/</loc> <loc>https://ael.is/</loc>
</url> </url>
<url> <url>
<loc>https://freya.chat/privacy</loc> <loc>https://ael.is/privacy</loc>
</url> </url>
</urlset> </urlset>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB