refactor(x/core): inject headless agent dependencies

This commit is contained in:
Ramnique Singh 2026-07-02 15:18:30 +05:30
parent eaa55df161
commit 0e316d1273
14 changed files with 191 additions and 80 deletions

View file

@ -4,7 +4,7 @@ import { IAgentScheduleRepo } from "./repo.js";
import { IAgentScheduleStateRepo } from "./state-repo.js"; import { IAgentScheduleStateRepo } from "./state-repo.js";
import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js"; import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js";
import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.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 { withUseCase } from "../analytics/use_case.js";
import z from "zod"; import z from "zod";

View file

@ -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<IHeadlessAgentRunner>("headlessAgentRunner");
}
export function startHeadlessAgent(
options: HeadlessAgentOptions,
): Promise<HeadlessAgentHandle> {
return runner().start(options);
}
export function runHeadlessAgent(
options: HeadlessAgentOptions,
): Promise<HeadlessAgentResult & { turnId: string }> {
return runner().run(options);
}
export { toolInputPaths } from "./headless.js";

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { z } from "zod"; import type { z } from "zod";
import { reduceTurn, type TurnEvent } from "@x/shared/dist/turns.js"; import { reduceTurn, type TurnEvent } from "@x/shared/dist/turns.js";
import type { import type {
@ -9,11 +9,12 @@ import type {
} from "../turns/api.js"; } from "../turns/api.js";
import { import {
HeadlessRunError, HeadlessRunError,
HeadlessAgentRunner,
assistantText, assistantText,
lastAssistantText, lastAssistantText,
startHeadlessAgent,
toolInputPaths, toolInputPaths,
} from "./headless.js"; } from "./headless.js";
import type { IDefaultModelResolver } from "../models/default-model-resolver.js";
type TEvent = z.infer<typeof TurnEvent>; type TEvent = z.infer<typeof TurnEvent>;
@ -166,6 +167,22 @@ const completedOutcome: TurnOutcome = {
usage: {}, 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", () => { describe("headless agent helpers", () => {
it("assistantText handles string and parts content", () => { it("assistantText handles string and parts content", () => {
expect(assistantText({ role: "assistant", content: "hi" })).toBe("hi"); 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 () => { it("creates a standalone auto-permission turn and returns the id before settling", async () => {
const runtime = new FakeRuntime(turnLog({ responseText: "the summary" }), completedOutcome); const runtime = new FakeRuntime(turnLog({ responseText: "the summary" }), completedOutcome);
const handle = await startHeadlessAgent( const { runner, resolve } = createRunner(runtime);
{ agentId: "worker", message: "go", model: "m", provider: "fake" }, const handle = await runner.start({
runtime, agentId: "worker",
); message: "go",
model: "m",
provider: "fake",
});
expect(handle.turnId).toBe(TURN_ID); expect(handle.turnId).toBe(TURN_ID);
expect(resolve).not.toHaveBeenCalled();
expect(runtime.createInputs[0]).toMatchObject({ expect(runtime.createInputs[0]).toMatchObject({
agent: { agentId: "worker", overrides: { model: { provider: "fake", model: "m" } } }, agent: { agentId: "worker", overrides: { model: { provider: "fake", model: "m" } } },
sessionId: null, sessionId: null,
@ -228,20 +249,38 @@ describe("startHeadlessAgent", () => {
it("omits the model override when neither model nor provider is set", async () => { it("omits the model override when neither model nor provider is set", async () => {
const runtime = new FakeRuntime(turnLog({ responseText: "ok" }), completedOutcome); 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(); 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 () => { it("throwOnError rejects done with HeadlessRunError on failed outcomes", async () => {
const runtime = new FakeRuntime(turnLog({ failed: "provider exploded" }), { const runtime = new FakeRuntime(turnLog({ failed: "provider exploded" }), {
status: "failed", status: "failed",
error: "provider exploded", error: "provider exploded",
usage: {}, usage: {},
}); });
const handle = await startHeadlessAgent( const { runner } = createRunner(runtime);
{ agentId: "worker", message: "go", throwOnError: true }, const handle = await runner.start({
runtime, agentId: "worker",
); message: "go",
throwOnError: true,
});
await expect(handle.done).rejects.toThrowError(HeadlessRunError); await expect(handle.done).rejects.toThrowError(HeadlessRunError);
await expect(handle.done).rejects.toThrowError("provider exploded"); await expect(handle.done).rejects.toThrowError("provider exploded");
}); });
@ -252,7 +291,8 @@ describe("startHeadlessAgent", () => {
error: "boom", error: "boom",
usage: {}, 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; const result = await handle.done;
expect(result.outcome.status).toBe("failed"); expect(result.outcome.status).toBe("failed");
expect(result.summary).toBeNull(); expect(result.summary).toBeNull();

View file

@ -4,14 +4,13 @@ import {
reduceTurn, reduceTurn,
type TurnState, type TurnState,
} from "@x/shared/dist/turns.js"; } 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 { ITurnRuntime, TurnOutcome } from "../turns/api.js";
import type { IDefaultModelResolver } from "../models/default-model-resolver.js";
// Drop-in replacement for the old headless runs pattern // Drop-in replacement for the old headless runs pattern
// (createRun → createMessage → waitForRunCompletion → extractAgentResponse): // (createRun → createMessage → waitForRunCompletion → extractAgentResponse):
// one standalone turn per invocation (sessionId null, automatic permissions, // 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 // record it in pointer files / bus events before completion); `done` settles
// with the outcome, the reduced turn state, and the final assistant text. // with the outcome, the reduced turn state, and the final assistant text.
@ -51,6 +50,18 @@ export interface HeadlessAgentHandle {
done: Promise<HeadlessAgentResult>; done: Promise<HeadlessAgentResult>;
} }
export interface HeadlessAgentRunnerDependencies {
turnRuntime: ITurnRuntime;
defaultModelResolver: IDefaultModelResolver;
}
export interface IHeadlessAgentRunner {
start(options: HeadlessAgentOptions): Promise<HeadlessAgentHandle>;
run(
options: HeadlessAgentOptions,
): Promise<HeadlessAgentResult & { turnId: string }>;
}
export function assistantText( export function assistantText(
message: z.infer<typeof AssistantMessage>, message: z.infer<typeof AssistantMessage>,
): string | null { ): string | null {
@ -97,63 +108,75 @@ export function toolInputPaths(
return paths; return paths;
} }
export async function startHeadlessAgent( export class HeadlessAgentRunner implements IHeadlessAgentRunner {
options: HeadlessAgentOptions, private readonly turnRuntime: ITurnRuntime;
// Injectable for tests; defaults to the app container's runtime. private readonly defaultModelResolver: IDefaultModelResolver;
turnRuntime: ITurnRuntime = container.resolve<ITurnRuntime>("turnRuntime"),
): Promise<HeadlessAgentHandle> { constructor({
let modelOverride: { provider: string; model: string } | undefined; turnRuntime,
if (options.model || options.provider) { defaultModelResolver,
const defaults = await getDefaultModelAndProvider(); }: HeadlessAgentRunnerDependencies) {
modelOverride = { this.turnRuntime = turnRuntime;
provider: options.provider ?? defaults.provider, this.defaultModelResolver = defaultModelResolver;
model: options.model ?? defaults.model,
};
} }
const turnId = await turnRuntime.createTurn({ async start(options: HeadlessAgentOptions): Promise<HeadlessAgentHandle> {
agent: { let modelOverride: { provider: string; model: string } | undefined;
agentId: options.agentId, if (options.model && options.provider) {
...(modelOverride ? { overrides: { model: modelOverride } } : {}), modelOverride = { provider: options.provider, model: options.model };
}, } else if (options.model || options.provider) {
sessionId: null, const defaults = await this.defaultModelResolver.resolve();
context: [], modelOverride = {
input: { role: "user", content: options.message }, provider: options.provider ?? defaults.provider,
config: { model: options.model ?? defaults.model,
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,
);
} }
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( const turnId = await this.turnRuntime.createTurn({
options: HeadlessAgentOptions, agent: {
turnRuntime?: ITurnRuntime, agentId: options.agentId,
): Promise<HeadlessAgentResult & { turnId: string }> { ...(modelOverride ? { overrides: { model: modelOverride } } : {}),
const handle = await startHeadlessAgent(options, turnRuntime); },
const result = await handle.done; sessionId: null,
return { turnId: handle.turnId, ...result }; 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<HeadlessAgentResult & { turnId: string }> {
const handle = await this.start(options);
const result = await handle.done;
return { turnId: handle.turnId, ...result };
}
} }

View file

@ -2,7 +2,7 @@ import type { BackgroundTask, BackgroundTaskTriggerType } from '@x/shared/dist/b
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js'; import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
import { fetchTask, patchTask, prependRunId } from './fileops.js'; import { fetchTask, patchTask, prependRunId } from './fileops.js';
import { getBackgroundTaskAgentModel } from '../models/defaults.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 { buildTriggerBlock } from '../agents/build-trigger-block.js';
import { backgroundTaskBus } from './bus.js'; import { backgroundTaskBus } from './bus.js';
import { withUseCase } from '../analytics/use_case.js'; import { withUseCase } from '../analytics/use_case.js';

View file

@ -49,6 +49,14 @@ import type { ISessionRepo } from "../sessions/repo.js";
import { EmitterSessionBus, type ISessionBus } from "../sessions/bus.js"; import { EmitterSessionBus, type ISessionBus } from "../sessions/bus.js";
import { SessionsImpl } from "../sessions/sessions.js"; import { SessionsImpl } from "../sessions/sessions.js";
import type { ISessions } from "../sessions/api.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({ const container = createContainer({
injectionMode: InjectionMode.PROXY, injectionMode: InjectionMode.PROXY,
@ -115,6 +123,10 @@ container.register({
sessionRepo: asClass<ISessionRepo>(FSSessionRepo).singleton(), sessionRepo: asClass<ISessionRepo>(FSSessionRepo).singleton(),
sessionBus: asClass<ISessionBus>(EmitterSessionBus).singleton(), sessionBus: asClass<ISessionBus>(EmitterSessionBus).singleton(),
sessions: asClass<ISessions>(SessionsImpl).singleton(), sessions: asClass<ISessions>(SessionsImpl).singleton(),
defaultModelResolver:
asClass<IDefaultModelResolver>(DefaultModelResolver).singleton(),
headlessAgentRunner:
asClass<IHeadlessAgentRunner>(HeadlessAgentRunner).singleton(),
}); });
export default container; export default container;

View file

@ -2,7 +2,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { google } from 'googleapis'; import { google } from 'googleapis';
import { WorkDir } from '../config/config.js'; 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 { getKgModel } from '../models/defaults.js';
import { getErrorDetails } from '../agents/utils.js'; import { getErrorDetails } from '../agents/utils.js';
import { serviceLogger } from '../services/service_logger.js'; import { serviceLogger } from '../services/service_logger.js';

View file

@ -2,7 +2,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { WorkDir } from '../config/config.js'; import { WorkDir } from '../config/config.js';
import { getKgModel } from '../models/defaults.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 { getErrorDetails } from '../agents/utils.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import { import {

View file

@ -3,7 +3,7 @@ import path from 'path';
import { CronExpressionParser } from 'cron-parser'; import { CronExpressionParser } from 'cron-parser';
import { generateText } from 'ai'; import { generateText } from 'ai';
import { WorkDir } from '../config/config.js'; 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 { getKgModel } from '../models/defaults.js';
import container from '../di/container.js'; import container from '../di/container.js';
import type { IModelConfigRepo } from '../models/repo.js'; import type { IModelConfigRepo } from '../models/repo.js';

View file

@ -1,7 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { WorkDir } from '../config/config.js'; 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 { getKgModel } from '../models/defaults.js';
import { getErrorDetails } from '../agents/utils.js'; import { getErrorDetails } from '../agents/utils.js';
import { serviceLogger } from '../services/service_logger.js'; import { serviceLogger } from '../services/service_logger.js';

View file

@ -1,7 +1,7 @@
import type { LiveNote, LiveNoteTriggerType } from '@x/shared/dist/live-note.js'; import type { LiveNote, LiveNoteTriggerType } from '@x/shared/dist/live-note.js';
import { fetchLiveNote, patchLiveNote, readNoteBody } from './fileops.js'; import { fetchLiveNote, patchLiveNote, readNoteBody } from './fileops.js';
import { getLiveNoteAgentModel } from '../../models/defaults.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 { withUseCase } from '../../analytics/use_case.js';
import { buildTriggerBlock } from '../../agents/build-trigger-block.js'; import { buildTriggerBlock } from '../../agents/build-trigger-block.js';
import { liveNoteBus } from './bus.js'; import { liveNoteBus } from './bus.js';

View file

@ -1,7 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { WorkDir } from '../config/config.js'; 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 { getKgModel } from '../models/defaults.js';
import { getErrorDetails } from '../agents/utils.js'; import { getErrorDetails } from '../agents/utils.js';
import { serviceLogger } from '../services/service_logger.js'; import { serviceLogger } from '../services/service_logger.js';

View file

@ -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();
}
}

View file

@ -1,7 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { WorkDir } from '../config/config.js'; 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 { getKgModel } from '../models/defaults.js';
import { import {
loadConfig, loadConfig,