feat: add structured room role metadata
This commit is contained in:
@@ -21,6 +21,11 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
import {
|
||||||
|
prependRoomRoleHeader,
|
||||||
|
type RoomRoleContext,
|
||||||
|
} from './room-role-context.js';
|
||||||
|
|
||||||
interface ContainerInput {
|
interface ContainerInput {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
@@ -30,6 +35,7 @@ interface ContainerInput {
|
|||||||
isScheduledTask?: boolean;
|
isScheduledTask?: boolean;
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
secrets?: Record<string, string>;
|
secrets?: Record<string, string>;
|
||||||
|
roomRoleContext?: RoomRoleContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mirrors AgentOutput in src/agent-runner.ts (separate package, can't import directly). */
|
/** Mirrors AgentOutput in src/agent-runner.ts (separate package, can't import directly). */
|
||||||
@@ -971,6 +977,7 @@ async function main(): Promise<void> {
|
|||||||
log(`Draining ${pending.length} pending IPC messages into initial prompt`);
|
log(`Draining ${pending.length} pending IPC messages into initial prompt`);
|
||||||
prompt += '\n' + pending.join('\n');
|
prompt += '\n' + pending.join('\n');
|
||||||
}
|
}
|
||||||
|
prompt = prependRoomRoleHeader(prompt, containerInput.roomRoleContext);
|
||||||
|
|
||||||
// --- Slash command handling ---
|
// --- Slash command handling ---
|
||||||
// Only known session slash commands are handled here. This prevents
|
// Only known session slash commands are handled here. This prevents
|
||||||
|
|||||||
23
runners/agent-runner/src/room-role-context.ts
Normal file
23
runners/agent-runner/src/room-role-context.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export interface RoomRoleContext {
|
||||||
|
serviceId: string;
|
||||||
|
role: 'owner' | 'reviewer';
|
||||||
|
ownerServiceId: string;
|
||||||
|
reviewerServiceId: string;
|
||||||
|
failoverOwner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependRoomRoleHeader(
|
||||||
|
prompt: string,
|
||||||
|
roomRoleContext?: RoomRoleContext,
|
||||||
|
): string {
|
||||||
|
if (!roomRoleContext) {
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header =
|
||||||
|
`[ROOM_ROLE self=${roomRoleContext.serviceId} role=${roomRoleContext.role} ` +
|
||||||
|
`owner=${roomRoleContext.ownerServiceId} reviewer=${roomRoleContext.reviewerServiceId} ` +
|
||||||
|
`failover=${roomRoleContext.failoverOwner ? 1 : 0}]`;
|
||||||
|
|
||||||
|
return `${header}\n\n${prompt}`;
|
||||||
|
}
|
||||||
23
runners/agent-runner/test/room-role-context.test.ts
Normal file
23
runners/agent-runner/test/room-role-context.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { prependRoomRoleHeader } from '../src/room-role-context.js';
|
||||||
|
|
||||||
|
describe('claude runner room role header', () => {
|
||||||
|
it('prepends a canonical role header when metadata exists', () => {
|
||||||
|
expect(
|
||||||
|
prependRoomRoleHeader('hello', {
|
||||||
|
serviceId: 'codex-review',
|
||||||
|
role: 'owner',
|
||||||
|
ownerServiceId: 'codex-review',
|
||||||
|
reviewerServiceId: 'codex-main',
|
||||||
|
failoverOwner: true,
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
'[ROOM_ROLE self=codex-review role=owner owner=codex-review reviewer=codex-main failover=1]\n\nhello',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves prompts unchanged without room metadata', () => {
|
||||||
|
expect(prependRoomRoleHeader('hello')).toBe('hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
CodexAppServerClient,
|
CodexAppServerClient,
|
||||||
type AppServerInputItem,
|
type AppServerInputItem,
|
||||||
} from './app-server-client.js';
|
} from './app-server-client.js';
|
||||||
|
import {
|
||||||
|
prependRoomRoleHeader,
|
||||||
|
type RoomRoleContext,
|
||||||
|
} from './room-role-context.js';
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ interface ContainerInput {
|
|||||||
isScheduledTask?: boolean;
|
isScheduledTask?: boolean;
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
agentType?: string;
|
agentType?: string;
|
||||||
|
roomRoleContext?: RoomRoleContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContainerOutput {
|
interface ContainerOutput {
|
||||||
@@ -464,6 +469,7 @@ async function main(): Promise<void> {
|
|||||||
if (pending.length > 0) {
|
if (pending.length > 0) {
|
||||||
prompt += '\n' + pending.join('\n');
|
prompt += '\n' + pending.join('\n');
|
||||||
}
|
}
|
||||||
|
prompt = prependRoomRoleHeader(prompt, containerInput.roomRoleContext);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log('Runtime selected: app-server');
|
log('Runtime selected: app-server');
|
||||||
|
|||||||
23
runners/codex-runner/src/room-role-context.ts
Normal file
23
runners/codex-runner/src/room-role-context.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export interface RoomRoleContext {
|
||||||
|
serviceId: string;
|
||||||
|
role: 'owner' | 'reviewer';
|
||||||
|
ownerServiceId: string;
|
||||||
|
reviewerServiceId: string;
|
||||||
|
failoverOwner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependRoomRoleHeader(
|
||||||
|
prompt: string,
|
||||||
|
roomRoleContext?: RoomRoleContext,
|
||||||
|
): string {
|
||||||
|
if (!roomRoleContext) {
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header =
|
||||||
|
`[ROOM_ROLE self=${roomRoleContext.serviceId} role=${roomRoleContext.role} ` +
|
||||||
|
`owner=${roomRoleContext.ownerServiceId} reviewer=${roomRoleContext.reviewerServiceId} ` +
|
||||||
|
`failover=${roomRoleContext.failoverOwner ? 1 : 0}]`;
|
||||||
|
|
||||||
|
return `${header}\n\n${prompt}`;
|
||||||
|
}
|
||||||
23
runners/codex-runner/test/room-role-context.test.ts
Normal file
23
runners/codex-runner/test/room-role-context.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { prependRoomRoleHeader } from '../src/room-role-context.js';
|
||||||
|
|
||||||
|
describe('codex runner room role header', () => {
|
||||||
|
it('prepends a canonical role header when metadata exists', () => {
|
||||||
|
expect(
|
||||||
|
prependRoomRoleHeader('hello', {
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
role: 'reviewer',
|
||||||
|
ownerServiceId: 'claude',
|
||||||
|
reviewerServiceId: 'codex-main',
|
||||||
|
failoverOwner: false,
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
'[ROOM_ROLE self=codex-main role=reviewer owner=claude reviewer=codex-main failover=0]\n\nhello',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves prompts unchanged without room metadata', () => {
|
||||||
|
expect(prependRoomRoleHeader('hello')).toBe('hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -312,6 +312,46 @@ describe('agent-runner timeout behavior', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serializes roomRoleContext into the runner stdin payload', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
fakeProc = createFakeProcess();
|
||||||
|
|
||||||
|
let stdinPayload = '';
|
||||||
|
fakeProc.stdin.on('data', (chunk) => {
|
||||||
|
stdinPayload += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultPromise = runAgentProcess(
|
||||||
|
testGroup,
|
||||||
|
{
|
||||||
|
...testInput,
|
||||||
|
roomRoleContext: {
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
role: 'reviewer',
|
||||||
|
ownerServiceId: 'claude',
|
||||||
|
reviewerServiceId: 'codex-main',
|
||||||
|
failoverOwner: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
async () => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
fakeProc.emit('close', 0);
|
||||||
|
const result = await resultPromise;
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
expect(JSON.parse(stdinPayload)).toMatchObject({
|
||||||
|
prompt: 'Hello',
|
||||||
|
roomRoleContext: {
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
role: 'reviewer',
|
||||||
|
ownerServiceId: 'claude',
|
||||||
|
reviewerServiceId: 'codex-main',
|
||||||
|
failoverOwner: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('isolates IPC and session directories for isolated scheduled tasks', async () => {
|
it('isolates IPC and session directories for isolated scheduled tasks', async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
fakeProc = createFakeProcess();
|
fakeProc = createFakeProcess();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
AgentOutputPhase,
|
AgentOutputPhase,
|
||||||
AgentType,
|
AgentType,
|
||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
|
RoomRoleContext,
|
||||||
StructuredAgentOutput,
|
StructuredAgentOutput,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export interface AgentInput {
|
|||||||
useTaskScopedSession?: boolean;
|
useTaskScopedSession?: boolean;
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
agentType?: 'claude-code' | 'codex';
|
agentType?: 'claude-code' | 'codex';
|
||||||
|
roomRoleContext?: RoomRoleContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentOutput {
|
export interface AgentOutput {
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ vi.mock('./config.js', () => ({
|
|||||||
CODEX_REVIEW_SERVICE_ID: 'codex-review',
|
CODEX_REVIEW_SERVICE_ID: 'codex-review',
|
||||||
DATA_DIR: '/tmp/ejclaw-test-data',
|
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||||
SERVICE_SESSION_SCOPE: 'claude',
|
SERVICE_SESSION_SCOPE: 'claude',
|
||||||
|
normalizeServiceId: vi.fn((serviceId: string) =>
|
||||||
|
serviceId === 'codex' ? 'codex-main' : serviceId,
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./db.js', () => ({
|
vi.mock('./db.js', () => ({
|
||||||
@@ -231,6 +234,32 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes paired-room role metadata through to the runner input', async () => {
|
||||||
|
const group = { ...makeGroup(), folder: 'test-group' };
|
||||||
|
|
||||||
|
await runAgentForGroup(makeDeps(), {
|
||||||
|
group,
|
||||||
|
prompt: 'hello',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-room-role',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
||||||
|
group,
|
||||||
|
expect.objectContaining({
|
||||||
|
roomRoleContext: {
|
||||||
|
serviceId: 'claude',
|
||||||
|
role: 'owner',
|
||||||
|
ownerServiceId: 'claude',
|
||||||
|
reviewerServiceId: 'codex-main',
|
||||||
|
failoverOwner: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('adds reviewer silence guidance when the current service is the reviewer for the chat', async () => {
|
it('adds reviewer silence guidance when the current service is the reviewer for the chat', async () => {
|
||||||
const group = { ...makeGroup(), folder: 'test-group' };
|
const group = { ...makeGroup(), folder: 'test-group' };
|
||||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { createServiceHandoff, getAllTasks } from './db.js';
|
|||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { buildRoomMemoryBriefing } from './memento-client.js';
|
import { buildRoomMemoryBriefing } from './memento-client.js';
|
||||||
|
import { buildRoomRoleContext } from './room-role-context.js';
|
||||||
import {
|
import {
|
||||||
classifyRotationTrigger,
|
classifyRotationTrigger,
|
||||||
type AgentTriggerReason,
|
type AgentTriggerReason,
|
||||||
@@ -121,6 +122,10 @@ export async function runAgentForGroup(
|
|||||||
const currentLease = getEffectiveChannelLease(chatJid);
|
const currentLease = getEffectiveChannelLease(chatJid);
|
||||||
const reviewerMode =
|
const reviewerMode =
|
||||||
currentLease.reviewer_service_id === SERVICE_SESSION_SCOPE;
|
currentLease.reviewer_service_id === SERVICE_SESSION_SCOPE;
|
||||||
|
const roomRoleContext = buildRoomRoleContext(
|
||||||
|
currentLease,
|
||||||
|
SERVICE_SESSION_SCOPE,
|
||||||
|
);
|
||||||
const effectivePrompt = buildStructuredOutputPrompt(prompt, {
|
const effectivePrompt = buildStructuredOutputPrompt(prompt, {
|
||||||
reviewerMode,
|
reviewerMode,
|
||||||
});
|
});
|
||||||
@@ -180,6 +185,7 @@ export async function runAgentForGroup(
|
|||||||
runId,
|
runId,
|
||||||
isMain,
|
isMain,
|
||||||
assistantName: deps.assistantName,
|
assistantName: deps.assistantName,
|
||||||
|
roomRoleContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
const runAttempt = async (
|
const runAttempt = async (
|
||||||
|
|||||||
65
src/room-role-context.test.ts
Normal file
65
src/room-role-context.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildRoomRoleContext } from './room-role-context.js';
|
||||||
|
|
||||||
|
describe('buildRoomRoleContext', () => {
|
||||||
|
it('returns reviewer context for a normal paired codex turn', () => {
|
||||||
|
expect(
|
||||||
|
buildRoomRoleContext(
|
||||||
|
{
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
activated_at: null,
|
||||||
|
reason: null,
|
||||||
|
explicit: false,
|
||||||
|
},
|
||||||
|
'codex-main',
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
role: 'reviewer',
|
||||||
|
ownerServiceId: 'claude',
|
||||||
|
reviewerServiceId: 'codex-main',
|
||||||
|
failoverOwner: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns owner failover context for a codex-review failover turn', () => {
|
||||||
|
expect(
|
||||||
|
buildRoomRoleContext(
|
||||||
|
{
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
owner_service_id: 'codex-review',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
activated_at: '2026-03-28T10:00:00.000Z',
|
||||||
|
reason: 'claude-429',
|
||||||
|
explicit: true,
|
||||||
|
},
|
||||||
|
'codex-review',
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
serviceId: 'codex-review',
|
||||||
|
role: 'owner',
|
||||||
|
ownerServiceId: 'codex-review',
|
||||||
|
reviewerServiceId: 'codex-main',
|
||||||
|
failoverOwner: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for a non-paired room', () => {
|
||||||
|
expect(
|
||||||
|
buildRoomRoleContext(
|
||||||
|
{
|
||||||
|
chat_jid: 'solo@test',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: null,
|
||||||
|
activated_at: null,
|
||||||
|
reason: null,
|
||||||
|
explicit: false,
|
||||||
|
},
|
||||||
|
'codex-main',
|
||||||
|
),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
43
src/room-role-context.ts
Normal file
43
src/room-role-context.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
CODEX_MAIN_SERVICE_ID,
|
||||||
|
CODEX_REVIEW_SERVICE_ID,
|
||||||
|
normalizeServiceId,
|
||||||
|
} from './config.js';
|
||||||
|
import type { RoomRoleContext } from './types.js';
|
||||||
|
import type { EffectiveChannelLease } from './service-routing.js';
|
||||||
|
|
||||||
|
export function buildRoomRoleContext(
|
||||||
|
lease: EffectiveChannelLease,
|
||||||
|
serviceId: string,
|
||||||
|
): RoomRoleContext | undefined {
|
||||||
|
const normalizedServiceId = normalizeServiceId(serviceId);
|
||||||
|
const reviewerServiceId = lease.reviewer_service_id
|
||||||
|
? normalizeServiceId(lease.reviewer_service_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!reviewerServiceId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerServiceId = normalizeServiceId(lease.owner_service_id);
|
||||||
|
const role =
|
||||||
|
ownerServiceId === normalizedServiceId
|
||||||
|
? 'owner'
|
||||||
|
: reviewerServiceId === normalizedServiceId
|
||||||
|
? 'reviewer'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
serviceId: normalizedServiceId,
|
||||||
|
role,
|
||||||
|
ownerServiceId,
|
||||||
|
reviewerServiceId,
|
||||||
|
failoverOwner:
|
||||||
|
ownerServiceId === CODEX_REVIEW_SERVICE_ID &&
|
||||||
|
reviewerServiceId === CODEX_MAIN_SERVICE_ID,
|
||||||
|
};
|
||||||
|
}
|
||||||
10
src/types.ts
10
src/types.ts
@@ -23,6 +23,16 @@ export type VisiblePhase = 'silent' | 'progress' | 'final';
|
|||||||
|
|
||||||
export type AgentVisibility = 'public' | 'silent';
|
export type AgentVisibility = 'public' | 'silent';
|
||||||
|
|
||||||
|
export type PairedRoomRole = 'owner' | 'reviewer';
|
||||||
|
|
||||||
|
export interface RoomRoleContext {
|
||||||
|
serviceId: string;
|
||||||
|
role: PairedRoomRole;
|
||||||
|
ownerServiceId: string;
|
||||||
|
reviewerServiceId: string;
|
||||||
|
failoverOwner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type StructuredAgentOutput =
|
export type StructuredAgentOutput =
|
||||||
| {
|
| {
|
||||||
visibility: 'public';
|
visibility: 'public';
|
||||||
|
|||||||
Reference in New Issue
Block a user