Explorar o código

feat(agents): add task rejection contract

Alvin Unreal hai 3 semanas
pai
achega
f8e23a20e3

+ 15 - 0
docs/background-orchestration.md

@@ -266,6 +266,21 @@ Include:
 - validation to run or report,
 - what not to do.
 
+### Task-fit rejections
+
+A specialist may reject a task outside its role, permissions, or available
+context instead of attempting partial work. It returns:
+
+```text
+<task_rejection>
+<reason>brief explanation for the orchestrator</reason>
+<recommended_agent>@agent-name when clear</recommended_agent>
+</task_rejection>
+```
+
+`recommended_agent` is optional. The orchestrator uses the reason to reroute
+or clarify the task and must not retry the unchanged task with the same agent.
+
 Good background task prompt:
 
 ```text

+ 3 - 2
src/agents/custom.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect, spyOn, test } from 'bun:test';
 import { DEFAULT_MODELS, type PluginConfig } from '../config';
 import { createAgents, getAgentConfigs } from './index';
+import { TASK_REJECTION_INSTRUCTION } from './task-rejection';
 
 describe('custom-agent creation', () => {
   test('infers custom agents from unknown keys', () => {
@@ -23,7 +24,7 @@ describe('custom-agent creation', () => {
     expect(customAgent).toBeDefined();
     expect(customAgent?.config.model).toBe('openai/gpt-5.6');
     expect(customAgent?.config.prompt).toBe(
-      'You are the custom reviewer agent.',
+      `You are the custom reviewer agent.\n\n${TASK_REJECTION_INSTRUCTION}`,
     );
   });
 
@@ -44,7 +45,7 @@ describe('custom-agent creation', () => {
 
     expect(customAgent).toBeDefined();
     expect(customAgent?.config.prompt).toBe(
-      'You are a custom subagent for auditing.',
+      `You are a custom subagent for auditing.\n\n${TASK_REJECTION_INSTRUCTION}`,
     );
 
     const orchestrator = agents.find((agent) => agent.name === 'orchestrator');

+ 53 - 0
src/agents/index.test.ts

@@ -14,6 +14,7 @@ import {
   getDisabledAgents,
   isSubagent,
 } from './index';
+import { TASK_REJECTION_INSTRUCTION } from './task-rejection';
 
 function councilConfig() {
   const parsed = CouncilConfigSchema.parse({
@@ -467,6 +468,58 @@ describe('agent classification', () => {
 });
 
 describe('createAgents', () => {
+  test('adds task-rejection instructions to every subagent after prompt resolution', () => {
+    const agents = createAgents({
+      disabled_agents: [],
+      council: councilConfig(),
+      agents: {
+        explorer: {
+          model: 'test/explorer',
+          prompt: 'Replacement explorer prompt.',
+        },
+        reviewer: {
+          model: 'test/reviewer',
+          prompt: 'Custom reviewer prompt.',
+        },
+      },
+      acpAgents: {
+        bridge: {
+          command: 'bridge-acp',
+          args: [],
+          env: {},
+          timeoutMs: 0,
+          permissionMode: 'ask',
+        },
+      },
+    });
+
+    const orchestrator = agents.find((agent) => agent.name === 'orchestrator');
+    const explorer = agents.find((agent) => agent.name === 'explorer');
+
+    expect(explorer?.config.prompt).toBe(
+      `Replacement explorer prompt.\n\n${TASK_REJECTION_INSTRUCTION}`,
+    );
+    expect(orchestrator?.config.prompt).not.toContain(
+      TASK_REJECTION_INSTRUCTION,
+    );
+    expect(agents.map((agent) => agent.name)).toEqual(
+      expect.arrayContaining([
+        'observer',
+        'council',
+        'councillor',
+        'councillor-alpha',
+        'reviewer',
+        'bridge',
+      ]),
+    );
+
+    for (const agent of agents.filter(
+      (agent) => agent.name !== 'orchestrator',
+    )) {
+      expect(agent.config.prompt).toContain(TASK_REJECTION_INSTRUCTION);
+    }
+  });
+
   test('creates all agents without config', () => {
     const agents = createAgents();
     const names = agents.map((a) => a.name);

+ 7 - 0
src/agents/index.ts

@@ -30,6 +30,7 @@ import {
   createOrchestratorAgent,
   resolvePrompt,
 } from './orchestrator';
+import { appendTaskRejectionInstruction } from './task-rejection';
 
 export type { AgentDefinition } from './orchestrator';
 
@@ -508,6 +509,12 @@ export function createAgents(
     ...councillorAgents,
   ];
 
+  for (const agent of allSubAgents) {
+    agent.config.prompt = appendTaskRejectionInstruction(
+      agent.config.prompt ?? '',
+    );
+  }
+
   // 3. Create Orchestrator (with its own overrides and custom prompts)
   // DEFAULT_MODELS.orchestrator is undefined; model is resolved via override or
   // left unset so the runtime chat.message hook can pick it from _modelArray.

+ 11 - 0
src/agents/orchestrator.test.ts

@@ -11,4 +11,15 @@ describe('orchestrator prompt', () => {
     expect(prompt).toContain('small bounded set of options');
     expect(prompt).toContain('ordinary dialogue that does not block work');
   });
+
+  test('treats task rejections as routing signals', () => {
+    const prompt = buildOrchestratorPrompt();
+
+    expect(prompt).toContain('Treat `<task_rejection>` as a routing signal');
+    expect(prompt).toContain('inspect its `<reason>`');
+    expect(prompt).toContain('optional `<recommended_agent>`');
+    expect(prompt).toContain(
+      'Never reissue an unchanged task to the same agent',
+    );
+  });
 });

+ 2 - 0
src/agents/orchestrator.ts

@@ -201,6 +201,8 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - Prefer \`task(..., background: true)\` for delegated work that can run independently.
 - For work already chosen for delegation, launch independent specialist lanes in the background so the orchestrator stays unblocked and can reconcile results when they return.
 - Track each task's specialist, objective, task/session ID, and file/topic ownership.
+- Treat \`<task_rejection>\` as a routing signal: inspect its \`<reason>\` and optional \`<recommended_agent>\`, then reroute or clarify.
+- Never reissue an unchanged task to the same agent after a rejection; adjust its scope or context before retrying.
 - Continue orchestration only on non-overlapping work; otherwise briefly report what was launched and stop.
 - Before local edits or another writer task, compare against running task scopes.
 - Parallel background tasks are allowed only when their write scopes do not conflict.

+ 11 - 0
src/agents/task-rejection.ts

@@ -0,0 +1,11 @@
+export const TASK_REJECTION_INSTRUCTION = `<TaskRejection>
+If the assignment is outside your role, permissions, or available context, reject it instead of attempting partial work. Respond exactly:
+<task_rejection>
+<reason>brief explanation for the orchestrator</reason>
+<recommended_agent>@agent-name when clear, otherwise omit this tag</recommended_agent>
+</task_rejection>
+</TaskRejection>`;
+
+export function appendTaskRejectionInstruction(prompt: string): string {
+  return `${prompt}\n\n${TASK_REJECTION_INSTRUCTION}`;
+}