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 { 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";

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 { 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<typeof TurnEvent>;
@ -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();

View file

@ -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<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(
message: z.infer<typeof AssistantMessage>,
): 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<ITurnRuntime>("turnRuntime"),
): Promise<HeadlessAgentHandle> {
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<HeadlessAgentHandle> {
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<HeadlessAgentResult & { turnId: string }> {
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<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 { 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';

View file

@ -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<ISessionRepo>(FSSessionRepo).singleton(),
sessionBus: asClass<ISessionBus>(EmitterSessionBus).singleton(),
sessions: asClass<ISessions>(SessionsImpl).singleton(),
defaultModelResolver:
asClass<IDefaultModelResolver>(DefaultModelResolver).singleton(),
headlessAgentRunner:
asClass<IHeadlessAgentRunner>(HeadlessAgentRunner).singleton(),
});
export default container;

View file

@ -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';

View file

@ -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 {

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

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 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,