Skip to content

Commit ddb0f44

Browse files
committed
feat(workflow): select configured agent profiles
1 parent 4786b10 commit ddb0f44

14 files changed

Lines changed: 251 additions & 17 deletions

src/db/migrations.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ const migrations: Migration[] = [
4242
name: "workflow-exact-replay",
4343
up: migrateWorkflowExactReplay,
4444
},
45+
{
46+
version: 8,
47+
name: "workflow-agent-profiles",
48+
up: migrateWorkflowAgentProfiles,
49+
},
4550
];
4651

4752
export function migrateDatabase(sqlite: Database.Database): void {
@@ -268,6 +273,8 @@ function migrateWorkflowJournal(sqlite: Database.Database): void {
268273
provider text not null,
269274
model text,
270275
effort text,
276+
profile_name text,
277+
profile_fingerprint text,
271278
label text,
272279
phase text,
273280
status text not null,
@@ -312,6 +319,11 @@ function migrateWorkflowExactReplay(sqlite: Database.Database): void {
312319
addColumnIfMissing(sqlite, "workflow_agent_calls", "return_value_json", "text");
313320
}
314321

322+
function migrateWorkflowAgentProfiles(sqlite: Database.Database): void {
323+
addColumnIfMissing(sqlite, "workflow_agent_calls", "profile_name", "text");
324+
addColumnIfMissing(sqlite, "workflow_agent_calls", "profile_fingerprint", "text");
325+
}
326+
315327
function addColumnIfMissing(
316328
sqlite: Database.Database,
317329
table: "workspace_sessions" | "local_agent_sessions" | "workflow_agent_calls",

src/db/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ export const workflowAgentCalls = sqliteTable(
162162
provider: text("provider").notNull(),
163163
model: text("model"),
164164
effort: text("effort"),
165+
profileName: text("profile_name"),
166+
profileFingerprint: text("profile_fingerprint"),
165167
label: text("label"),
166168
phase: text("phase"),
167169
status: text("status").notNull(),

src/oauth-store.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise<void> {
4848
{ version: 5, name: "workflow-journal" },
4949
{ version: 6, name: "workflow-replay-provenance" },
5050
{ version: 7, name: "workflow-exact-replay" },
51+
{ version: 8, name: "workflow-agent-profiles" },
5152
]);
5253
} finally {
5354
database.close();

src/workflow-api.ts

Lines changed: 91 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { AsyncLocalStorage } from "node:async_hooks";
22
import { createHash } from "node:crypto";
33
import type { WorkflowSandboxApi } from "./workflow-sandbox.js";
4-
import type { LocalAgentProvider } from "./local-agent-profiles.js";
4+
import {
5+
buildLocalAgentProfilePrompt,
6+
fingerprintLocalAgentProfile,
7+
type LocalAgentProfile,
8+
type LocalAgentProvider,
9+
} from "./local-agent-profiles.js";
510
import type { JsonSchema, JsonValue } from "./json-types.js";
611
import { jsonValueSchema } from "./json-types.js";
712
import {
@@ -109,6 +114,8 @@ export interface WorkflowJournal {
109114
provider: LocalAgentProvider;
110115
model?: string;
111116
effort?: string;
117+
profileName?: string;
118+
profileFingerprint?: string;
112119
label?: string;
113120
phase?: string;
114121
isolation?: AgentIsolationMode;
@@ -151,6 +158,8 @@ export interface WorkflowApiDeps {
151158
baseSha?: string;
152159
/** Already-filtered enabled ∩ live provider ids, preference order. */
153160
enabledProviders: LocalAgentProvider[];
161+
/** Loaded, enabled profiles exposed by open_workspace for this project. */
162+
agentProfiles?: LocalAgentProfile[];
154163
runProvider: WorkflowRunProvider;
155164
createWorktree?: CreateAgentWorktree;
156165
replay?: WorkflowReplay;
@@ -177,6 +186,7 @@ export class WorkflowEngineError extends Error {
177186
| "provider_disabled"
178187
| "provider_unavailable"
179188
| "no_provider"
189+
| "profile"
180190
| "nest_depth"
181191
| "worktree"
182192
| "schema"
@@ -250,7 +260,15 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
250260
const agentOpts = normalizeAgentOpts(opts);
251261
throwIfCancelled(deps);
252262

253-
const provider = resolveProvider(agentOpts.provider, deps.meta, deps.enabledProviders);
263+
const target = resolveAgentTarget(prompt, agentOpts, deps);
264+
const {
265+
provider,
266+
model,
267+
effort,
268+
profileName,
269+
profileFingerprint,
270+
providerPrompt,
271+
} = target;
254272
const phase = agentOpts.phase ?? phaseAls.getStore();
255273
const isolation: AgentIsolationMode =
256274
agentOpts.isolation === "worktree" ? "worktree" : "shared";
@@ -259,9 +277,11 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
259277

260278
const cacheKeyInput = buildAgentCacheKeyInput({
261279
prompt,
280+
profileName,
281+
profileFingerprint,
262282
provider,
263-
model: agentOpts.model,
264-
effort: agentOpts.effort,
283+
model,
284+
effort,
265285
schema: agentOpts.schema,
266286
isolation,
267287
});
@@ -277,8 +297,10 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
277297
prompt,
278298
schemaJson: agentOpts.schema ? JSON.stringify(agentOpts.schema) : undefined,
279299
provider,
280-
model: agentOpts.model,
281-
effort: agentOpts.effort,
300+
model,
301+
effort,
302+
profileName,
303+
profileFingerprint,
282304
label: agentOpts.label,
283305
phase,
284306
isolation,
@@ -349,8 +371,10 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
349371
prompt,
350372
schemaJson: agentOpts.schema ? JSON.stringify(agentOpts.schema) : undefined,
351373
provider,
352-
model: agentOpts.model,
353-
effort: agentOpts.effort,
374+
model,
375+
effort,
376+
profileName,
377+
profileFingerprint,
354378
label: agentOpts.label,
355379
phase,
356380
isolation,
@@ -377,9 +401,9 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
377401
const cwd = worktreePath ?? deps.workspaceRoot;
378402
const providerBase = {
379403
provider,
380-
prompt,
381-
model: agentOpts.model,
382-
effort: agentOpts.effort,
404+
prompt: providerPrompt,
405+
model,
406+
effort,
383407
workspace: cwd,
384408
signal: deps.signal,
385409
label: agentOpts.label,
@@ -395,7 +419,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
395419
const { enforceAgentSchema } = await import("./workflow-schema.js");
396420
const enforced = await enforceAgentSchema({
397421
schema: agentOpts.schema,
398-
prompt,
422+
prompt: providerPrompt,
399423
provider,
400424
run: (p, options) =>
401425
deps.runProvider({
@@ -690,6 +714,54 @@ export function resolveProvider(
690714
return first;
691715
}
692716

717+
interface ResolvedAgentTarget {
718+
provider: LocalAgentProvider;
719+
model?: string;
720+
effort?: string;
721+
profileName?: string;
722+
profileFingerprint?: string;
723+
providerPrompt: string;
724+
}
725+
726+
function resolveAgentTarget(
727+
prompt: string,
728+
opts: AgentOpts,
729+
deps: Pick<WorkflowApiDeps, "agentProfiles" | "enabledProviders" | "meta">,
730+
): ResolvedAgentTarget {
731+
if (!opts.profile) {
732+
return {
733+
provider: resolveProvider(opts.provider, deps.meta, deps.enabledProviders),
734+
model: opts.model,
735+
effort: opts.effort,
736+
providerPrompt: prompt,
737+
};
738+
}
739+
740+
const profile = deps.agentProfiles?.find((candidate) => candidate.name === opts.profile);
741+
if (!profile) {
742+
const available = deps.agentProfiles?.map((candidate) => candidate.name).join(", ");
743+
throw new WorkflowEngineError(
744+
"profile",
745+
`Unknown agent profile: ${opts.profile}${available ? `. Available profiles: ${available}` : ""}`,
746+
);
747+
}
748+
if (!deps.enabledProviders.includes(profile.provider)) {
749+
throw new WorkflowEngineError(
750+
"provider_unavailable",
751+
`Agent profile ${profile.name} requires unavailable provider ${profile.provider}`,
752+
);
753+
}
754+
755+
return {
756+
provider: profile.provider,
757+
model: opts.model ?? profile.model,
758+
effort: opts.effort ?? profile.effort,
759+
profileName: profile.name,
760+
profileFingerprint: fingerprintLocalAgentProfile(profile),
761+
providerPrompt: buildLocalAgentProfilePrompt(profile, prompt),
762+
};
763+
}
764+
693765
function normalizeAgentOpts(opts: unknown): AgentOpts {
694766
if (opts === undefined || opts === null) return {};
695767
if (typeof opts === "object" && opts !== null && "writeMode" in opts) {
@@ -699,7 +771,13 @@ function normalizeAgentOpts(opts: unknown): AgentOpts {
699771
if (parsed.success) return parsed.data;
700772
const issue = parsed.error.issues[0];
701773
const path = issue?.path.join(".") || "opts";
702-
const kind = path === "schema" ? "schema" : path === "isolation" ? "worktree" : "internal";
774+
const kind = path === "schema"
775+
? "schema"
776+
: path === "isolation"
777+
? "worktree"
778+
: path === "profile" || issue?.message.includes("profile and provider")
779+
? "profile"
780+
: "internal";
703781
throw new WorkflowEngineError(
704782
kind,
705783
`Invalid agent ${path}: ${issue?.message ?? "validation failed"}`,

src/workflow-cli.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { runLocalAgentProviderResult } from "./local-agent-adapters.js";
99
import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js";
1010
import {
1111
isLocalAgentProvider,
12+
loadLocalAgentProfiles,
1213
LOCAL_AGENT_PROVIDERS,
1314
type LocalAgentProvider,
1415
} from "./local-agent-profiles.js";
@@ -346,6 +347,7 @@ export async function runWorkflowWorker(
346347
const source = await readFile(claimed.scriptPath, "utf8");
347348
const parsed = parseWorkflowScript(source, { filename: claimed.scriptPath });
348349
const enabledProviders = resolveEnabledProviders(config.agentProviders);
350+
const agentProfiles = await loadLocalAgentProfiles(config, claimed.workspaceRoot);
349351
const concurrency = resolveWorkflowConcurrency(
350352
parsed.meta.concurrency,
351353
availableParallelism(),
@@ -378,6 +380,7 @@ export async function runWorkflowWorker(
378380
workspaceRoot: claimed.workspaceRoot,
379381
baseSha: claimed.baseSha,
380382
enabledProviders,
383+
agentProfiles,
381384
createWorktree,
382385
replay,
383386
runProvider: async (input) => {

src/workflow-contracts.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,20 @@ export const agentOptsSchema = z
6060
schema: jsonSchemaSchema.optional(),
6161
model: z.string().trim().min(1).optional(),
6262
effort: z.string().trim().min(1).optional(),
63+
profile: z.string().trim().min(1).optional(),
6364
provider: localAgentProviderSchema.optional(),
6465
isolation: z.literal("worktree").optional(),
6566
})
66-
.strict();
67+
.strict()
68+
.superRefine((value, context) => {
69+
if (value.profile && value.provider) {
70+
context.addIssue({
71+
code: "custom",
72+
path: ["provider"],
73+
message: "profile and provider are mutually exclusive",
74+
});
75+
}
76+
});
6777

6878
export type AgentOpts<S extends JsonSchema | undefined = JsonSchema | undefined> = Omit<
6979
z.infer<typeof agentOptsSchema>,
@@ -115,6 +125,7 @@ export const workflowErrorKindSchema = z.enum([
115125
"provider_unavailable",
116126
"no_provider",
117127
"provider",
128+
"profile",
118129
"schema",
119130
"cancelled",
120131
"timeout",

src/workflow-engine.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type CreateAgentWorktree,
1414
} from "./workflow-api.js";
1515
import { createStubBudget, WORKFLOW_LIMITS } from "./workflow-types.js";
16+
import type { LocalAgentProfile } from "./local-agent-profiles.js";
1617

1718
// ---------------------------------------------------------------------------
1819
// Semaphore
@@ -327,6 +328,96 @@ import { createStubBudget, WORKFLOW_LIMITS } from "./workflow-types.js";
327328
await rm(dir, { recursive: true, force: true });
328329
}
329330

331+
// ---------------------------------------------------------------------------
332+
// configured profile selection, defaults, overrides, and prompt instructions
333+
// ---------------------------------------------------------------------------
334+
{
335+
const dir = await mkdtemp(join(tmpdir(), "wf-profile-"));
336+
const store = new WorkflowStore(dir);
337+
const run = store.createRun({
338+
name: "profile",
339+
source: "inline",
340+
scriptPath: "inline",
341+
scriptHash: "h",
342+
workspaceRoot: dir,
343+
});
344+
const profile: LocalAgentProfile = {
345+
name: "reviewer",
346+
description: "Review changes",
347+
provider: "claude",
348+
model: "sonnet",
349+
effort: "medium",
350+
filePath: join(dir, "reviewer.md"),
351+
body: "Act as an adversarial reviewer.",
352+
disabled: false,
353+
};
354+
const calls: WorkflowProviderRunInput[] = [];
355+
const api = createWorkflowApi({
356+
runId: run.id,
357+
journal: store,
358+
meta: { name: "profile", description: "d", defaultProvider: "codex" },
359+
args: undefined,
360+
concurrency: 1,
361+
signal: new AbortController().signal,
362+
workspaceRoot: dir,
363+
enabledProviders: ["codex", "claude"],
364+
agentProfiles: [profile],
365+
runProvider: async (input) => {
366+
calls.push(input);
367+
return { finalResponse: "reviewed" };
368+
},
369+
});
370+
371+
assert.equal(
372+
await api.agent("Review auth", {
373+
profile: "reviewer",
374+
model: "opus",
375+
effort: "high",
376+
}),
377+
"reviewed",
378+
);
379+
assert.equal(calls[0]?.provider, "claude");
380+
assert.equal(calls[0]?.model, "opus");
381+
assert.equal(calls[0]?.effort, "high");
382+
assert.equal(
383+
calls[0]?.prompt,
384+
"Act as an adversarial reviewer.\n\nTask:\nReview auth",
385+
);
386+
assert.equal(store.getAgentCall(run.id, 0)?.profileName, "reviewer");
387+
assert.equal(store.getAgentCall(run.id, 0)?.profileFingerprint?.length, 64);
388+
389+
const callAgent = api.agent as (prompt: string, opts?: unknown) => Promise<unknown>;
390+
await assert.rejects(
391+
() => callAgent("x", { profile: "reviewer", provider: "codex" }),
392+
/mutually exclusive/,
393+
);
394+
await assert.rejects(() => callAgent("x", { profile: "missing" }), /Unknown agent profile/);
395+
396+
const unavailableApi = createWorkflowApi({
397+
runId: run.id,
398+
journal: store,
399+
meta: { name: "profile", description: "d" },
400+
args: undefined,
401+
concurrency: 1,
402+
signal: new AbortController().signal,
403+
workspaceRoot: dir,
404+
enabledProviders: ["codex"],
405+
agentProfiles: [profile],
406+
runProvider: async () => ({ finalResponse: "unreachable" }),
407+
});
408+
await assert.rejects(
409+
() =>
410+
(unavailableApi.agent as (prompt: string, opts?: unknown) => Promise<unknown>)(
411+
"x",
412+
{ profile: "reviewer" },
413+
),
414+
/requires unavailable provider claude/,
415+
);
416+
417+
store.close();
418+
await rm(dir, { recursive: true, force: true });
419+
}
420+
330421
// ---------------------------------------------------------------------------
331422
// schema retry: native schema only on first attempt + provider session reuse
332423
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)