mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
refactor(x/core): inject headless agent dependencies
This commit is contained in:
parent
eaa55df161
commit
0e316d1273
14 changed files with 191 additions and 80 deletions
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
25
apps/x/packages/core/src/agents/headless-app.ts
Normal file
25
apps/x/packages/core/src/agents/headless-app.ts
Normal 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";
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
11
apps/x/packages/core/src/models/default-model-resolver.ts
Normal file
11
apps/x/packages/core/src/models/default-model-resolver.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue