Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@
"@agent-relay/harness-driver": "^8.2.0",
"@agent-relay/harnesses": "^8.2.0",
"@agent-relay/sdk": "^8.2.0",
"@agentworkforce/persona-kit": "^4.1.38",
"@agentworkforce/persona-registry": "^4.1.38",
"@agentworkforce/persona-kit": "^4.1.39",
"@agentworkforce/persona-registry": "^4.1.39",
"@relaycast/sdk": "^1.1.0",
"@relayfile/sdk": "^0.8.0",
"@relayflows/browser-primitive": "1.0.5",
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/__tests__/e2e-owner-review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,9 @@ describe('PR #511 E2E: Auto Step Owner + Review Gating', () => {

const spawnResults = (mockRelayInstance.spawnPty as any).mock.results;
const reviewAgent = await spawnResults[spawnResults.length - 1].value;
expect(reviewAgent.waitForExit).toHaveBeenCalledWith(600_000);
const [[reviewWaitMs]] = reviewAgent.waitForExit.mock.calls;
expect(reviewWaitMs).toBeGreaterThanOrEqual(599_000);
expect(reviewWaitMs).toBeLessThanOrEqual(600_000);
}, 15000);
});

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/__tests__/workflow-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ agents:
agent: 'integration-expert',
task: 'Fix the failed sync',
timeoutMs: 1,
retries: 0,
},
],
},
Expand Down
18 changes: 10 additions & 8 deletions packages/core/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { RuntimeSpawnOptions } from '@agent-relay/harness-driver';
import type {
AgentCli,
AgentCredentialConfig,
AgentConstraints,
AgentDefinition,
AgentPermissions,
AgentPreset,
Expand Down Expand Up @@ -332,11 +333,11 @@ export class WorkflowBuilder {

/** Add an agent definition. */
agent(name: string, options: AgentOptions): this {
const def: AgentDefinition = {
const def = {
name,
...(options.cli ? { cli: options.cli } : {}),
...(options.persona ? { persona: options.persona } : {}),
};
} as AgentDefinition;
Comment on lines +336 to +340

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Build the correct AgentDefinition variant.

AgentDefinition permits constraints.model only for CLI agents. It also forbids role, preset, and interactive: false for persona agents. The cast at Line 340 bypasses this union, and Lines 366-373 copy options.model without checking options.persona. The builder can emit both cli and persona, or emit a persona with constraints.model. toYaml() can then return invalid configuration that WorkflowRunner.validateConfig rejects later.

Construct CLI and persona branches separately. Reject incompatible options before adding the definition.

Proposed guard
-    const def = {
-      name,
-      ...(options.cli ? { cli: options.cli } : {}),
-      ...(options.persona ? { persona: options.persona } : {}),
-    } as AgentDefinition;
+    const hasCli = options.cli !== undefined;
+    const hasPersona = options.persona !== undefined;
+    if (hasCli === hasPersona) {
+      throw new Error(`Agent "${name}" must define exactly one of "cli" or "persona"`);
+    }
+    if (
+      hasPersona &&
+      (options.model !== undefined ||
+        options.role !== undefined ||
+        options.preset !== undefined ||
+        options.interactive === false)
+    ) {
+      throw new Error(`Agent "${name}" has options that are not supported for persona agents`);
+    }
+    const def: AgentDefinition = hasCli
+      ? { name, cli: options.cli! }
+      : { name, persona: options.persona! };

-      if (options.model !== undefined) constraints.model = options.model;
+      if (hasCli && options.model !== undefined) constraints.model = options.model;

Also applies to: 366-373

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/builder.ts` around lines 336 - 340, Update the
AgentDefinition construction in the builder to use separate CLI and persona
branches instead of casting the combined object. Validate and reject
incompatible options before constructing the definition: prevent both cli and
persona from being emitted, allow constraints.model only for CLI agents, and
disallow role, preset, and interactive:false for persona agents. Ensure the
options.model handling around the existing model-copy logic is gated by the CLI
branch so toYaml() produces a valid union.


if (options.role !== undefined) def.role = options.role;
if (options.task !== undefined) def.task = options.task;
Expand All @@ -362,13 +363,14 @@ export class WorkflowBuilder {
options.retries !== undefined ||
options.idleThresholdSecs !== undefined
) {
def.constraints = {};
if (options.model !== undefined) def.constraints.model = options.model;
if (options.maxTokens !== undefined) def.constraints.maxTokens = options.maxTokens;
if (options.timeoutMs !== undefined) def.constraints.timeoutMs = options.timeoutMs;
if (options.retries !== undefined) def.constraints.retries = options.retries;
const constraints: AgentConstraints = {};
if (options.model !== undefined) constraints.model = options.model;
if (options.maxTokens !== undefined) constraints.maxTokens = options.maxTokens;
if (options.timeoutMs !== undefined) constraints.timeoutMs = options.timeoutMs;
if (options.retries !== undefined) constraints.retries = options.retries;
if (options.idleThresholdSecs !== undefined)
def.constraints.idleThresholdSecs = options.idleThresholdSecs;
constraints.idleThresholdSecs = options.idleThresholdSecs;
def.constraints = constraints;
}

this._agents.push(def);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/persona-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function resolveWorkflowPersona(reference: string, cwd: string): Resolved
? built
: { ...built, mount: { ignoredPatterns: [], readonlyPatterns: [] } };
const args = plan.initialPrompt ? [...plan.args, plan.initialPrompt] : [...plan.args];
if (plan.cli === 'api' || !getCliDefinition(plan.cli)) {
if (!getCliDefinition(plan.cli)) {
throw new Error(
`Persona "${resolved.spec.id}" resolves to unsupported interactive CLI "${plan.cli}"`
);
Expand Down
20 changes: 16 additions & 4 deletions packages/core/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2125,7 +2125,7 @@ export class WorkflowRunner {
return explicitProvider;
}

const model = agentDef.constraints?.model?.trim().toLowerCase() ?? '';
const model = WorkflowRunner.agentConstraintModel(agentDef)?.trim().toLowerCase() ?? '';
if (model.includes('openrouter')) {
return 'openrouter';
}
Expand Down Expand Up @@ -4566,8 +4566,12 @@ export class WorkflowRunner {
}

private async runDeterministicRepairAgent(context: DeterministicRepairContext): Promise<void> {
const repairAgent: AgentDefinition = {
if (!context.agentDef.cli) {
throw new Error(`Repair agent "${context.agentDef.name}" must be a raw CLI agent`);
}
const repairAgent: Extract<AgentDefinition, { cli: AgentCli }> = {
...context.agentDef,
cli: context.agentDef.cli,
interactive: false,
};
const repairPrompt = this.buildDeterministicRepairPrompt(context);
Expand Down Expand Up @@ -4661,8 +4665,12 @@ export class WorkflowRunner {
}

private async runAgentStepRepairAgent(context: AgentStepRepairContext): Promise<void> {
const repairAgent: AgentDefinition = {
if (!context.agentDef.cli) {
throw new Error(`Repair agent "${context.agentDef.name}" must be a raw CLI agent`);
}
const repairAgent: Extract<AgentDefinition, { cli: AgentCli }> = {
...context.agentDef,
cli: context.agentDef.cli,
interactive: false,
};
const repairPrompt = this.buildAgentStepRepairPrompt(context);
Expand Down Expand Up @@ -7056,6 +7064,10 @@ export class WorkflowRunner {
return { ...defaults, ...def, cli: resolvedCli } as AgentDefinition;
}

private static agentConstraintModel(def: AgentDefinition): string | undefined {
return def.cli ? def.constraints?.model : undefined;
}

/**
* Returns a preset-specific prefix that is prepended to the non-interactive
* enforcement block in execNonInteractive.
Expand Down Expand Up @@ -7459,7 +7471,7 @@ export class WorkflowRunner {
: undefined;
const spawnOptions = {
name: agentName,
model: personaResolution?.model ?? agentDef.constraints?.model,
model: personaResolution?.model ?? WorkflowRunner.agentConstraintModel(agentDef),
args: personaResolution?.args ?? interactiveSpawnPolicy.args,
channels: agentChannels,
task: preparedTask.spawnTaskText,
Expand Down