From 0e316d12734f1b926ff74b4e3e389c8d09faac7d Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:18:30 +0530 Subject: [PATCH] refactor(x/core): inject headless agent dependencies --- .../core/src/agent-schedule/runner.ts | 2 +- .../packages/core/src/agents/headless-app.ts | 25 ++++ .../packages/core/src/agents/headless.test.ts | 66 +++++++-- apps/x/packages/core/src/agents/headless.ts | 139 ++++++++++-------- .../core/src/background-tasks/runner.ts | 2 +- apps/x/packages/core/src/di/container.ts | 12 ++ .../core/src/knowledge/agent_notes.ts | 2 +- .../core/src/knowledge/build_graph.ts | 2 +- .../core/src/knowledge/inline_tasks.ts | 2 +- .../core/src/knowledge/label_emails.ts | 2 +- .../core/src/knowledge/live-note/runner.ts | 2 +- .../packages/core/src/knowledge/tag_notes.ts | 2 +- .../core/src/models/default-model-resolver.ts | 11 ++ apps/x/packages/core/src/pre_built/runner.ts | 2 +- 14 files changed, 191 insertions(+), 80 deletions(-) create mode 100644 apps/x/packages/core/src/agents/headless-app.ts create mode 100644 apps/x/packages/core/src/models/default-model-resolver.ts diff --git a/apps/x/packages/core/src/agent-schedule/runner.ts b/apps/x/packages/core/src/agent-schedule/runner.ts index e34c9e11..bd818388 100644 --- a/apps/x/packages/core/src/agent-schedule/runner.ts +++ b/apps/x/packages/core/src/agent-schedule/runner.ts @@ -4,7 +4,7 @@ import { IAgentScheduleRepo } from "./repo.js"; import { IAgentScheduleStateRepo } from "./state-repo.js"; import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js"; import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js"; -import { startHeadlessAgent } from "../agents/headless.js"; +import { startHeadlessAgent } from "../agents/headless-app.js"; import { withUseCase } from "../analytics/use_case.js"; import z from "zod"; diff --git a/apps/x/packages/core/src/agents/headless-app.ts b/apps/x/packages/core/src/agents/headless-app.ts new file mode 100644 index 00000000..d9b78f3a --- /dev/null +++ b/apps/x/packages/core/src/agents/headless-app.ts @@ -0,0 +1,25 @@ +import container from "../di/container.js"; +import { + type HeadlessAgentHandle, + type HeadlessAgentOptions, + type HeadlessAgentResult, + type IHeadlessAgentRunner, +} from "./headless.js"; + +function runner(): IHeadlessAgentRunner { + return container.resolve("headlessAgentRunner"); +} + +export function startHeadlessAgent( + options: HeadlessAgentOptions, +): Promise { + return runner().start(options); +} + +export function runHeadlessAgent( + options: HeadlessAgentOptions, +): Promise { + return runner().run(options); +} + +export { toolInputPaths } from "./headless.js"; diff --git a/apps/x/packages/core/src/agents/headless.test.ts b/apps/x/packages/core/src/agents/headless.test.ts index 202bfbce..1f756ffd 100644 --- a/apps/x/packages/core/src/agents/headless.test.ts +++ b/apps/x/packages/core/src/agents/headless.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { z } from "zod"; import { reduceTurn, type TurnEvent } from "@x/shared/dist/turns.js"; import type { @@ -9,11 +9,12 @@ import type { } from "../turns/api.js"; import { HeadlessRunError, + HeadlessAgentRunner, assistantText, lastAssistantText, - startHeadlessAgent, toolInputPaths, } from "./headless.js"; +import type { IDefaultModelResolver } from "../models/default-model-resolver.js"; type TEvent = z.infer; @@ -166,6 +167,22 @@ const completedOutcome: TurnOutcome = { usage: {}, }; +function createRunner( + runtime: ITurnRuntime, + resolve = vi.fn(async () => ({ + provider: "default-provider", + model: "default-model", + })), +): { runner: HeadlessAgentRunner; resolve: IDefaultModelResolver["resolve"] } { + return { + runner: new HeadlessAgentRunner({ + turnRuntime: runtime, + defaultModelResolver: { resolve }, + }), + resolve, + }; +} + describe("headless agent helpers", () => { it("assistantText handles string and parts content", () => { expect(assistantText({ role: "assistant", content: "hi" })).toBe("hi"); @@ -206,14 +223,18 @@ describe("headless agent helpers", () => { }); }); -describe("startHeadlessAgent", () => { +describe("HeadlessAgentRunner", () => { it("creates a standalone auto-permission turn and returns the id before settling", async () => { const runtime = new FakeRuntime(turnLog({ responseText: "the summary" }), completedOutcome); - const handle = await startHeadlessAgent( - { agentId: "worker", message: "go", model: "m", provider: "fake" }, - runtime, - ); + const { runner, resolve } = createRunner(runtime); + const handle = await runner.start({ + agentId: "worker", + message: "go", + model: "m", + provider: "fake", + }); expect(handle.turnId).toBe(TURN_ID); + expect(resolve).not.toHaveBeenCalled(); expect(runtime.createInputs[0]).toMatchObject({ agent: { agentId: "worker", overrides: { model: { provider: "fake", model: "m" } } }, sessionId: null, @@ -228,20 +249,38 @@ describe("startHeadlessAgent", () => { it("omits the model override when neither model nor provider is set", async () => { const runtime = new FakeRuntime(turnLog({ responseText: "ok" }), completedOutcome); - await startHeadlessAgent({ agentId: "worker", message: "go" }, runtime); + const { runner, resolve } = createRunner(runtime); + await runner.start({ agentId: "worker", message: "go" }); + expect(resolve).not.toHaveBeenCalled(); expect(runtime.createInputs[0].agent.overrides).toBeUndefined(); }); + it("uses an injected default only for a partial model override", async () => { + const runtime = new FakeRuntime(turnLog({ responseText: "ok" }), completedOutcome); + const { runner, resolve } = createRunner(runtime); + await runner.start({ + agentId: "worker", + message: "go", + model: "custom-model", + }); + expect(resolve).toHaveBeenCalledOnce(); + expect(runtime.createInputs[0].agent.overrides).toEqual({ + model: { provider: "default-provider", model: "custom-model" }, + }); + }); + it("throwOnError rejects done with HeadlessRunError on failed outcomes", async () => { const runtime = new FakeRuntime(turnLog({ failed: "provider exploded" }), { status: "failed", error: "provider exploded", usage: {}, }); - const handle = await startHeadlessAgent( - { agentId: "worker", message: "go", throwOnError: true }, - runtime, - ); + const { runner } = createRunner(runtime); + const handle = await runner.start({ + agentId: "worker", + message: "go", + throwOnError: true, + }); await expect(handle.done).rejects.toThrowError(HeadlessRunError); await expect(handle.done).rejects.toThrowError("provider exploded"); }); @@ -252,7 +291,8 @@ describe("startHeadlessAgent", () => { error: "boom", usage: {}, }); - const handle = await startHeadlessAgent({ agentId: "worker", message: "go" }, runtime); + const { runner } = createRunner(runtime); + const handle = await runner.start({ agentId: "worker", message: "go" }); const result = await handle.done; expect(result.outcome.status).toBe("failed"); expect(result.summary).toBeNull(); diff --git a/apps/x/packages/core/src/agents/headless.ts b/apps/x/packages/core/src/agents/headless.ts index 54f20826..d974a5fd 100644 --- a/apps/x/packages/core/src/agents/headless.ts +++ b/apps/x/packages/core/src/agents/headless.ts @@ -4,14 +4,13 @@ import { reduceTurn, type TurnState, } from "@x/shared/dist/turns.js"; -import container from "../di/container.js"; -import { getDefaultModelAndProvider } from "../models/defaults.js"; import type { ITurnRuntime, TurnOutcome } from "../turns/api.js"; +import type { IDefaultModelResolver } from "../models/default-model-resolver.js"; // Drop-in replacement for the old headless runs pattern // (createRun → createMessage → waitForRunCompletion → extractAgentResponse): // one standalone turn per invocation (sessionId null, automatic permissions, -// no human). startHeadlessAgent returns the turn id immediately (callers +// no human). HeadlessAgentRunner.start returns the turn id immediately (callers // record it in pointer files / bus events before completion); `done` settles // with the outcome, the reduced turn state, and the final assistant text. @@ -51,6 +50,18 @@ export interface HeadlessAgentHandle { done: Promise; } +export interface HeadlessAgentRunnerDependencies { + turnRuntime: ITurnRuntime; + defaultModelResolver: IDefaultModelResolver; +} + +export interface IHeadlessAgentRunner { + start(options: HeadlessAgentOptions): Promise; + run( + options: HeadlessAgentOptions, + ): Promise; +} + export function assistantText( message: z.infer, ): string | null { @@ -97,63 +108,75 @@ export function toolInputPaths( return paths; } -export async function startHeadlessAgent( - options: HeadlessAgentOptions, - // Injectable for tests; defaults to the app container's runtime. - turnRuntime: ITurnRuntime = container.resolve("turnRuntime"), -): Promise { - let modelOverride: { provider: string; model: string } | undefined; - if (options.model || options.provider) { - const defaults = await getDefaultModelAndProvider(); - modelOverride = { - provider: options.provider ?? defaults.provider, - model: options.model ?? defaults.model, - }; +export class HeadlessAgentRunner implements IHeadlessAgentRunner { + private readonly turnRuntime: ITurnRuntime; + private readonly defaultModelResolver: IDefaultModelResolver; + + constructor({ + turnRuntime, + defaultModelResolver, + }: HeadlessAgentRunnerDependencies) { + this.turnRuntime = turnRuntime; + this.defaultModelResolver = defaultModelResolver; } - const turnId = await turnRuntime.createTurn({ - agent: { - agentId: options.agentId, - ...(modelOverride ? { overrides: { model: modelOverride } } : {}), - }, - sessionId: null, - context: [], - input: { role: "user", content: options.message }, - config: { - autoPermission: true, - humanAvailable: false, - ...(options.maxModelCalls === undefined - ? {} - : { maxModelCalls: options.maxModelCalls }), - }, - }); - - const execution = turnRuntime.advanceTurn(turnId, undefined, { - signal: options.signal, - }); - const done = execution.outcome.then(async (outcome) => { - const state = reduceTurn((await turnRuntime.getTurn(turnId)).events); - if (options.throwOnError && outcome.status !== "completed") { - throw new HeadlessRunError( - outcome.status === "failed" - ? outcome.error - : `turn ${outcome.status}`, - turnId, - outcome, - ); + async start(options: HeadlessAgentOptions): Promise { + let modelOverride: { provider: string; model: string } | undefined; + if (options.model && options.provider) { + modelOverride = { provider: options.provider, model: options.model }; + } else if (options.model || options.provider) { + const defaults = await this.defaultModelResolver.resolve(); + modelOverride = { + provider: options.provider ?? defaults.provider, + model: options.model ?? defaults.model, + }; } - return { outcome, state, summary: lastAssistantText(state) }; - }); - // The handle may be used fire-and-forget; rejections surface when awaited. - done.catch(() => undefined); - return { turnId, done }; -} -export async function runHeadlessAgent( - options: HeadlessAgentOptions, - turnRuntime?: ITurnRuntime, -): Promise { - const handle = await startHeadlessAgent(options, turnRuntime); - const result = await handle.done; - return { turnId: handle.turnId, ...result }; + const turnId = await this.turnRuntime.createTurn({ + agent: { + agentId: options.agentId, + ...(modelOverride ? { overrides: { model: modelOverride } } : {}), + }, + sessionId: null, + context: [], + input: { role: "user", content: options.message }, + config: { + autoPermission: true, + humanAvailable: false, + ...(options.maxModelCalls === undefined + ? {} + : { maxModelCalls: options.maxModelCalls }), + }, + }); + + const execution = this.turnRuntime.advanceTurn(turnId, undefined, { + signal: options.signal, + }); + const done = execution.outcome.then(async (outcome) => { + const state = reduceTurn( + (await this.turnRuntime.getTurn(turnId)).events, + ); + if (options.throwOnError && outcome.status !== "completed") { + throw new HeadlessRunError( + outcome.status === "failed" + ? outcome.error + : `turn ${outcome.status}`, + turnId, + outcome, + ); + } + return { outcome, state, summary: lastAssistantText(state) }; + }); + // The handle may be used fire-and-forget; rejections surface when awaited. + done.catch(() => undefined); + return { turnId, done }; + } + + async run( + options: HeadlessAgentOptions, + ): Promise { + const handle = await this.start(options); + const result = await handle.done; + return { turnId: handle.turnId, ...result }; + } } diff --git a/apps/x/packages/core/src/background-tasks/runner.ts b/apps/x/packages/core/src/background-tasks/runner.ts index 98f56d90..700f203a 100644 --- a/apps/x/packages/core/src/background-tasks/runner.ts +++ b/apps/x/packages/core/src/background-tasks/runner.ts @@ -2,7 +2,7 @@ import type { BackgroundTask, BackgroundTaskTriggerType } from '@x/shared/dist/b import { PrefixLogger } from '@x/shared/dist/prefix-logger.js'; import { fetchTask, patchTask, prependRunId } from './fileops.js'; import { getBackgroundTaskAgentModel } from '../models/defaults.js'; -import { startHeadlessAgent } from '../agents/headless.js'; +import { startHeadlessAgent } from '../agents/headless-app.js'; import { buildTriggerBlock } from '../agents/build-trigger-block.js'; import { backgroundTaskBus } from './bus.js'; import { withUseCase } from '../analytics/use_case.js'; diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index e9be949f..3455f1b7 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -49,6 +49,14 @@ import type { ISessionRepo } from "../sessions/repo.js"; import { EmitterSessionBus, type ISessionBus } from "../sessions/bus.js"; import { SessionsImpl } from "../sessions/sessions.js"; import type { ISessions } from "../sessions/api.js"; +import { + DefaultModelResolver, + type IDefaultModelResolver, +} from "../models/default-model-resolver.js"; +import { + HeadlessAgentRunner, + type IHeadlessAgentRunner, +} from "../agents/headless.js"; const container = createContainer({ injectionMode: InjectionMode.PROXY, @@ -115,6 +123,10 @@ container.register({ sessionRepo: asClass(FSSessionRepo).singleton(), sessionBus: asClass(EmitterSessionBus).singleton(), sessions: asClass(SessionsImpl).singleton(), + defaultModelResolver: + asClass(DefaultModelResolver).singleton(), + headlessAgentRunner: + asClass(HeadlessAgentRunner).singleton(), }); export default container; diff --git a/apps/x/packages/core/src/knowledge/agent_notes.ts b/apps/x/packages/core/src/knowledge/agent_notes.ts index 8d0b0ab1..091b4d78 100644 --- a/apps/x/packages/core/src/knowledge/agent_notes.ts +++ b/apps/x/packages/core/src/knowledge/agent_notes.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { google } from 'googleapis'; import { WorkDir } from '../config/config.js'; -import { runHeadlessAgent } from '../agents/headless.js'; +import { runHeadlessAgent } from '../agents/headless-app.js'; import { getKgModel } from '../models/defaults.js'; import { getErrorDetails } from '../agents/utils.js'; import { serviceLogger } from '../services/service_logger.js'; diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts index f0c8836a..03dcef73 100644 --- a/apps/x/packages/core/src/knowledge/build_graph.ts +++ b/apps/x/packages/core/src/knowledge/build_graph.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { WorkDir } from '../config/config.js'; import { getKgModel } from '../models/defaults.js'; -import { runHeadlessAgent, toolInputPaths } from '../agents/headless.js'; +import { runHeadlessAgent, toolInputPaths } from '../agents/headless-app.js'; import { getErrorDetails } from '../agents/utils.js'; import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { diff --git a/apps/x/packages/core/src/knowledge/inline_tasks.ts b/apps/x/packages/core/src/knowledge/inline_tasks.ts index 7bf92364..f24972ed 100644 --- a/apps/x/packages/core/src/knowledge/inline_tasks.ts +++ b/apps/x/packages/core/src/knowledge/inline_tasks.ts @@ -3,7 +3,7 @@ import path from 'path'; import { CronExpressionParser } from 'cron-parser'; import { generateText } from 'ai'; import { WorkDir } from '../config/config.js'; -import { runHeadlessAgent } from '../agents/headless.js'; +import { runHeadlessAgent } from '../agents/headless-app.js'; import { getKgModel } from '../models/defaults.js'; import container from '../di/container.js'; import type { IModelConfigRepo } from '../models/repo.js'; diff --git a/apps/x/packages/core/src/knowledge/label_emails.ts b/apps/x/packages/core/src/knowledge/label_emails.ts index e91732cf..db462d93 100644 --- a/apps/x/packages/core/src/knowledge/label_emails.ts +++ b/apps/x/packages/core/src/knowledge/label_emails.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import { WorkDir } from '../config/config.js'; -import { runHeadlessAgent, toolInputPaths } from '../agents/headless.js'; +import { runHeadlessAgent, toolInputPaths } from '../agents/headless-app.js'; import { getKgModel } from '../models/defaults.js'; import { getErrorDetails } from '../agents/utils.js'; import { serviceLogger } from '../services/service_logger.js'; diff --git a/apps/x/packages/core/src/knowledge/live-note/runner.ts b/apps/x/packages/core/src/knowledge/live-note/runner.ts index 64b1f2b8..61b2b0b0 100644 --- a/apps/x/packages/core/src/knowledge/live-note/runner.ts +++ b/apps/x/packages/core/src/knowledge/live-note/runner.ts @@ -1,7 +1,7 @@ import type { LiveNote, LiveNoteTriggerType } from '@x/shared/dist/live-note.js'; import { fetchLiveNote, patchLiveNote, readNoteBody } from './fileops.js'; import { getLiveNoteAgentModel } from '../../models/defaults.js'; -import { startHeadlessAgent } from '../../agents/headless.js'; +import { startHeadlessAgent } from '../../agents/headless-app.js'; import { withUseCase } from '../../analytics/use_case.js'; import { buildTriggerBlock } from '../../agents/build-trigger-block.js'; import { liveNoteBus } from './bus.js'; diff --git a/apps/x/packages/core/src/knowledge/tag_notes.ts b/apps/x/packages/core/src/knowledge/tag_notes.ts index a1d36bcb..70d451f4 100644 --- a/apps/x/packages/core/src/knowledge/tag_notes.ts +++ b/apps/x/packages/core/src/knowledge/tag_notes.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import { WorkDir } from '../config/config.js'; -import { runHeadlessAgent, toolInputPaths } from '../agents/headless.js'; +import { runHeadlessAgent, toolInputPaths } from '../agents/headless-app.js'; import { getKgModel } from '../models/defaults.js'; import { getErrorDetails } from '../agents/utils.js'; import { serviceLogger } from '../services/service_logger.js'; diff --git a/apps/x/packages/core/src/models/default-model-resolver.ts b/apps/x/packages/core/src/models/default-model-resolver.ts new file mode 100644 index 00000000..dff99247 --- /dev/null +++ b/apps/x/packages/core/src/models/default-model-resolver.ts @@ -0,0 +1,11 @@ +import { getDefaultModelAndProvider } from "./defaults.js"; + +export interface IDefaultModelResolver { + resolve(): Promise<{ model: string; provider: string }>; +} + +export class DefaultModelResolver implements IDefaultModelResolver { + resolve(): Promise<{ model: string; provider: string }> { + return getDefaultModelAndProvider(); + } +} diff --git a/apps/x/packages/core/src/pre_built/runner.ts b/apps/x/packages/core/src/pre_built/runner.ts index 3be9bcaf..19414cc3 100644 --- a/apps/x/packages/core/src/pre_built/runner.ts +++ b/apps/x/packages/core/src/pre_built/runner.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import { WorkDir } from '../config/config.js'; -import { runHeadlessAgent } from '../agents/headless.js'; +import { runHeadlessAgent } from '../agents/headless-app.js'; import { getKgModel } from '../models/defaults.js'; import { loadConfig,