feat: add structured silent output contract
This commit is contained in:
@@ -40,6 +40,10 @@ interface ContainerOutput {
|
|||||||
agentLabel?: string;
|
agentLabel?: string;
|
||||||
agentDone?: boolean;
|
agentDone?: boolean;
|
||||||
result: string | null;
|
result: string | null;
|
||||||
|
output?: {
|
||||||
|
visibility: 'public' | 'silent';
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
newSessionId?: string;
|
newSessionId?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@@ -191,6 +195,48 @@ function writeOutput(output: ContainerOutput): void {
|
|||||||
console.log(OUTPUT_END_MARKER);
|
console.log(OUTPUT_END_MARKER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStructuredOutput(result: string | null): {
|
||||||
|
result: string | null;
|
||||||
|
output?: ContainerOutput['output'];
|
||||||
|
} {
|
||||||
|
if (typeof result !== 'string' || result.length === 0) {
|
||||||
|
return { result };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = result.trim();
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as {
|
||||||
|
ejclaw?: { visibility?: unknown; text?: unknown };
|
||||||
|
};
|
||||||
|
const envelope = parsed?.ejclaw;
|
||||||
|
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
|
||||||
|
if (envelope.visibility === 'silent') {
|
||||||
|
return {
|
||||||
|
result: null,
|
||||||
|
output: { visibility: 'silent' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
envelope.visibility === 'public' &&
|
||||||
|
typeof envelope.text === 'string' &&
|
||||||
|
envelope.text.length > 0
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
result: envelope.text,
|
||||||
|
output: { visibility: 'public', text: envelope.text },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to legacy string output
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
output: { visibility: 'public', text: result },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function log(message: string): void {
|
function log(message: string): void {
|
||||||
console.error(`[agent-runner] ${message}`);
|
console.error(`[agent-runner] ${message}`);
|
||||||
}
|
}
|
||||||
@@ -715,10 +761,11 @@ async function runQuery(
|
|||||||
const description = typeof tp.description === 'string' ? tp.description : '';
|
const description = typeof tp.description === 'string' ? tp.description : '';
|
||||||
if (description && description.length <= 80) {
|
if (description && description.length <= 80) {
|
||||||
// Short tool description → show as sub-line in progress
|
// Short tool description → show as sub-line in progress
|
||||||
|
const normalized = normalizeStructuredOutput(description);
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'tool-activity',
|
phase: 'tool-activity',
|
||||||
result: description,
|
...normalized,
|
||||||
agentId: taskId,
|
agentId: taskId,
|
||||||
newSessionId,
|
newSessionId,
|
||||||
});
|
});
|
||||||
@@ -733,10 +780,11 @@ async function runQuery(
|
|||||||
const desc = ts.description || '';
|
const desc = ts.description || '';
|
||||||
log(`Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`);
|
log(`Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`);
|
||||||
if (desc) {
|
if (desc) {
|
||||||
|
const normalized = normalizeStructuredOutput(`🔄 ${desc}`);
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: `🔄 ${desc}`,
|
...normalized,
|
||||||
agentId: ts.task_id,
|
agentId: ts.task_id,
|
||||||
agentLabel: desc,
|
agentLabel: desc,
|
||||||
newSessionId,
|
newSessionId,
|
||||||
@@ -751,10 +799,11 @@ async function runQuery(
|
|||||||
};
|
};
|
||||||
const label = `${tp.tool_name} (${Math.round(tp.elapsed_time_seconds)}s)`;
|
const label = `${tp.tool_name} (${Math.round(tp.elapsed_time_seconds)}s)`;
|
||||||
log(`Tool progress: ${label}`);
|
log(`Tool progress: ${label}`);
|
||||||
|
const normalized = normalizeStructuredOutput(label);
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: label,
|
...normalized,
|
||||||
newSessionId,
|
newSessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -762,10 +811,11 @@ async function runQuery(
|
|||||||
if (message.type === 'tool_use_summary') {
|
if (message.type === 'tool_use_summary') {
|
||||||
const ts = message as { summary: string };
|
const ts = message as { summary: string };
|
||||||
log(`Tool use summary: ${ts.summary.slice(0, 200)}`);
|
log(`Tool use summary: ${ts.summary.slice(0, 200)}`);
|
||||||
|
const normalized = normalizeStructuredOutput(ts.summary);
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: ts.summary,
|
...normalized,
|
||||||
newSessionId,
|
newSessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -806,9 +856,10 @@ async function runQuery(
|
|||||||
error: errorText || `Agent error: ${message.subtype}`,
|
error: errorText || `Agent error: ${message.subtype}`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const normalized = normalizeStructuredOutput(textResult || null);
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: textResult || null,
|
...normalized,
|
||||||
newSessionId
|
newSessionId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -837,7 +888,7 @@ async function runQuery(
|
|||||||
);
|
);
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: textResult,
|
...normalizeStructuredOutput(textResult),
|
||||||
newSessionId,
|
newSessionId,
|
||||||
});
|
});
|
||||||
terminalResultObserved = true;
|
terminalResultObserved = true;
|
||||||
@@ -852,10 +903,11 @@ async function runQuery(
|
|||||||
if (stopReason !== 'end_turn' && textResult) {
|
if (stopReason !== 'end_turn' && textResult) {
|
||||||
// Flush previous pending as a regular message (not progress heading)
|
// Flush previous pending as a regular message (not progress heading)
|
||||||
if (pendingProgressText) {
|
if (pendingProgressText) {
|
||||||
|
const normalized = normalizeStructuredOutput(pendingProgressText);
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'intermediate',
|
phase: 'intermediate',
|
||||||
result: pendingProgressText,
|
...normalized,
|
||||||
newSessionId,
|
newSessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ interface ContainerInput {
|
|||||||
interface ContainerOutput {
|
interface ContainerOutput {
|
||||||
status: 'success' | 'error';
|
status: 'success' | 'error';
|
||||||
result: string | null;
|
result: string | null;
|
||||||
|
output?: {
|
||||||
|
visibility: 'public' | 'silent';
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
phase?: 'progress' | 'final';
|
phase?: 'progress' | 'final';
|
||||||
newSessionId?: string;
|
newSessionId?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -68,6 +72,48 @@ function writeOutput(output: ContainerOutput): void {
|
|||||||
console.log(OUTPUT_END_MARKER);
|
console.log(OUTPUT_END_MARKER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStructuredOutput(result: string | null): {
|
||||||
|
result: string | null;
|
||||||
|
output?: ContainerOutput['output'];
|
||||||
|
} {
|
||||||
|
if (typeof result !== 'string' || result.length === 0) {
|
||||||
|
return { result };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = result.trim();
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as {
|
||||||
|
ejclaw?: { visibility?: unknown; text?: unknown };
|
||||||
|
};
|
||||||
|
const envelope = parsed?.ejclaw;
|
||||||
|
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
|
||||||
|
if (envelope.visibility === 'silent') {
|
||||||
|
return {
|
||||||
|
result: null,
|
||||||
|
output: { visibility: 'silent' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
envelope.visibility === 'public' &&
|
||||||
|
typeof envelope.text === 'string' &&
|
||||||
|
envelope.text.length > 0
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
result: envelope.text,
|
||||||
|
output: { visibility: 'public', text: envelope.text },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to legacy string output
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
output: { visibility: 'public', text: result },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function log(message: string): void {
|
function log(message: string): void {
|
||||||
console.error(`[codex-runner] ${message}`);
|
console.error(`[codex-runner] ${message}`);
|
||||||
}
|
}
|
||||||
@@ -206,7 +252,7 @@ async function executeAppServerTurn(
|
|||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
result: trimmed,
|
...normalizeStructuredOutput(trimmed),
|
||||||
newSessionId: threadId,
|
newSessionId: threadId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -351,17 +397,19 @@ async function runAppServerSession(
|
|||||||
const { result, error } = await executeAppServerTurn(client, threadId, prompt);
|
const { result, error } = await executeAppServerTurn(client, threadId, prompt);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
const normalized = normalizeStructuredOutput(result || null);
|
||||||
log(`App-server turn error: ${error}`);
|
log(`App-server turn error: ${error}`);
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
result: result || null,
|
...normalized,
|
||||||
newSessionId: threadId,
|
newSessionId: threadId,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const normalized = normalizeStructuredOutput(result || null);
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: result || null,
|
...normalized,
|
||||||
...(result ? { phase: 'final' as const } : {}),
|
...(result ? { phase: 'final' as const } : {}),
|
||||||
newSessionId: threadId,
|
newSessionId: threadId,
|
||||||
});
|
});
|
||||||
|
|||||||
37
src/agent-output.ts
Normal file
37
src/agent-output.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { StructuredAgentOutput } from './types.js';
|
||||||
|
|
||||||
|
export function stringifyLegacyAgentResult(
|
||||||
|
result: string | object | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (result === null || result === undefined) return null;
|
||||||
|
if (typeof result === 'string') return result;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(result);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentOutputText(
|
||||||
|
output: {
|
||||||
|
output?: StructuredAgentOutput;
|
||||||
|
result?: string | object | null;
|
||||||
|
},
|
||||||
|
): string | null {
|
||||||
|
if (output.output?.visibility === 'silent') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (output.output?.visibility === 'public') {
|
||||||
|
return output.output.text;
|
||||||
|
}
|
||||||
|
return stringifyLegacyAgentResult(output.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSilentAgentOutput(
|
||||||
|
output: {
|
||||||
|
output?: StructuredAgentOutput;
|
||||||
|
},
|
||||||
|
): boolean {
|
||||||
|
return output.output?.visibility === 'silent';
|
||||||
|
}
|
||||||
@@ -20,7 +20,12 @@ export {
|
|||||||
} from './agent-runner-snapshot.js';
|
} from './agent-runner-snapshot.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js';
|
import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js';
|
||||||
import { AgentOutputPhase, AgentType, RegisteredGroup } from './types.js';
|
import {
|
||||||
|
AgentOutputPhase,
|
||||||
|
AgentType,
|
||||||
|
RegisteredGroup,
|
||||||
|
StructuredAgentOutput,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
export interface AgentInput {
|
export interface AgentInput {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -40,6 +45,7 @@ export interface AgentInput {
|
|||||||
export interface AgentOutput {
|
export interface AgentOutput {
|
||||||
status: 'success' | 'error';
|
status: 'success' | 'error';
|
||||||
result: string | null;
|
result: string | null;
|
||||||
|
output?: StructuredAgentOutput;
|
||||||
phase?: AgentOutputPhase;
|
phase?: AgentOutputPhase;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
agentLabel?: string;
|
agentLabel?: string;
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
||||||
group,
|
group,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
prompt: 'hello',
|
prompt: expect.stringContaining('hello'),
|
||||||
sessionId: undefined,
|
sessionId: undefined,
|
||||||
memoryBriefing: '## Shared Room Memory\n- remembered context',
|
memoryBriefing: '## Shared Room Memory\n- remembered context',
|
||||||
}),
|
}),
|
||||||
@@ -199,7 +199,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
||||||
group,
|
group,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
prompt: 'hello again',
|
prompt: expect.stringContaining('hello again'),
|
||||||
sessionId: 'session-existing',
|
sessionId: 'session-existing',
|
||||||
memoryBriefing: undefined,
|
memoryBriefing: undefined,
|
||||||
}),
|
}),
|
||||||
@@ -208,7 +208,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('injects suppress token instructions into the agent prompt', async () => {
|
it('injects structured silent-output instructions into the agent prompt', async () => {
|
||||||
const group = { ...makeGroup(), folder: 'test-group' };
|
const group = { ...makeGroup(), folder: 'test-group' };
|
||||||
|
|
||||||
await runAgentForGroup(makeDeps(), {
|
await runAgentForGroup(makeDeps(), {
|
||||||
@@ -223,7 +223,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
group,
|
group,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
prompt: expect.stringContaining(
|
prompt: expect.stringContaining(
|
||||||
'If you have no user-visible content to send for this turn, output exactly this token and nothing else: __TEST_SUPPRESS__',
|
'If you have no user-visible content to send for this turn, output exactly this JSON and nothing else: {"ejclaw":{"visibility":"silent"}}',
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
@@ -254,7 +254,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
group,
|
group,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
prompt: expect.stringContaining(
|
prompt: expect.stringContaining(
|
||||||
'If you are only agreeing, mirroring, or restating without adding a concrete correction, risk, missing prerequisite, test gap, or code change, output only the token.',
|
'If you are only agreeing, mirroring, or restating without adding a concrete correction, risk, missing prerequisite, test gap, or code change, output only the JSON object.',
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getErrorMessage } from './utils.js';
|
import { getErrorMessage } from './utils.js';
|
||||||
|
|
||||||
|
import { getAgentOutputText } from './agent-output.js';
|
||||||
import {
|
import {
|
||||||
AgentOutput,
|
AgentOutput,
|
||||||
runAgentProcess,
|
runAgentProcess,
|
||||||
@@ -26,7 +27,7 @@ import {
|
|||||||
SERVICE_SESSION_SCOPE,
|
SERVICE_SESSION_SCOPE,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import {
|
import {
|
||||||
buildSuppressTokenPrompt,
|
buildStructuredOutputPrompt,
|
||||||
classifySuppressTokenOutput,
|
classifySuppressTokenOutput,
|
||||||
} from './output-suppression.js';
|
} from './output-suppression.js';
|
||||||
import {
|
import {
|
||||||
@@ -120,7 +121,7 @@ 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 effectivePrompt = buildSuppressTokenPrompt(prompt, suppressToken, {
|
const effectivePrompt = buildStructuredOutputPrompt(prompt, {
|
||||||
reviewerMode,
|
reviewerMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,9 +230,10 @@ export async function runAgentForGroup(
|
|||||||
});
|
});
|
||||||
streamedState = evaluation.state;
|
streamedState = evaluation.state;
|
||||||
|
|
||||||
|
const outputText = getAgentOutputText(output);
|
||||||
if (
|
if (
|
||||||
evaluation.newTrigger &&
|
evaluation.newTrigger &&
|
||||||
typeof output.result === 'string' &&
|
typeof outputText === 'string' &&
|
||||||
output.status === 'success'
|
output.status === 'success'
|
||||||
) {
|
) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -240,7 +242,7 @@ export async function runAgentForGroup(
|
|||||||
group: group.name,
|
group: group.name,
|
||||||
runId,
|
runId,
|
||||||
reason: evaluation.newTrigger.reason,
|
reason: evaluation.newTrigger.reason,
|
||||||
resultPreview: output.result.slice(0, 120),
|
resultPreview: outputText.slice(0, 120),
|
||||||
},
|
},
|
||||||
'Detected Claude rotation trigger in successful output',
|
'Detected Claude rotation trigger in successful output',
|
||||||
);
|
);
|
||||||
@@ -269,8 +271,8 @@ export async function runAgentForGroup(
|
|||||||
group: group.name,
|
group: group.name,
|
||||||
runId,
|
runId,
|
||||||
resultPreview:
|
resultPreview:
|
||||||
typeof output.result === 'string'
|
typeof outputText === 'string'
|
||||||
? output.result.slice(0, 120)
|
? outputText.slice(0, 120)
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
'Suppressed Claude 401 auth error from chat output',
|
'Suppressed Claude 401 auth error from chat output',
|
||||||
@@ -285,8 +287,8 @@ export async function runAgentForGroup(
|
|||||||
group: group.name,
|
group: group.name,
|
||||||
runId,
|
runId,
|
||||||
resultPreview:
|
resultPreview:
|
||||||
typeof output.result === 'string'
|
typeof outputText === 'string'
|
||||||
? output.result.slice(0, 160)
|
? outputText.slice(0, 160)
|
||||||
: output.error?.slice(0, 160),
|
: output.error?.slice(0, 160),
|
||||||
},
|
},
|
||||||
'Suppressed retryable Claude session failure from chat output',
|
'Suppressed retryable Claude session failure from chat output',
|
||||||
@@ -298,12 +300,12 @@ export async function runAgentForGroup(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const suppressState =
|
const suppressState =
|
||||||
typeof output.result === 'string'
|
typeof outputText === 'string'
|
||||||
? classifySuppressTokenOutput(output.result, suppressToken)
|
? classifySuppressTokenOutput(outputText, suppressToken)
|
||||||
: 'none';
|
: 'none';
|
||||||
if (
|
if (
|
||||||
typeof output.result === 'string' &&
|
typeof outputText === 'string' &&
|
||||||
output.result.length > 0 &&
|
outputText.length > 0 &&
|
||||||
suppressState === 'none'
|
suppressState === 'none'
|
||||||
) {
|
) {
|
||||||
streamedState = {
|
streamedState = {
|
||||||
|
|||||||
@@ -1424,6 +1424,65 @@ describe('createMessageRuntime', () => {
|
|||||||
expect(channel.setTyping).not.toHaveBeenCalledWith(chatJid, true);
|
expect(channel.setTyping).not.toHaveBeenCalledWith(chatJid, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not emit a visible message when the final output is structured silent output', async () => {
|
||||||
|
const chatJid = 'group@test';
|
||||||
|
const group = makeGroup('claude-code');
|
||||||
|
const channel = makeChannel(chatJid);
|
||||||
|
const lastAgentTimestamps: Record<string, string> = {};
|
||||||
|
const saveState = vi.fn();
|
||||||
|
|
||||||
|
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 'msg-1',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender: 'user@test',
|
||||||
|
sender_name: 'User',
|
||||||
|
content: 'hello',
|
||||||
|
timestamp: '2026-03-19T00:00:00.000Z',
|
||||||
|
seq: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({
|
||||||
|
status: 'success',
|
||||||
|
result: null,
|
||||||
|
output: { visibility: 'silent' },
|
||||||
|
newSessionId: 'session-structured-silent-run',
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = createMessageRuntime({
|
||||||
|
assistantName: 'Andy',
|
||||||
|
idleTimeout: 1_000,
|
||||||
|
pollInterval: 1_000,
|
||||||
|
timezone: 'UTC',
|
||||||
|
triggerPattern: /^@Andy\b/i,
|
||||||
|
channels: [channel],
|
||||||
|
queue: {
|
||||||
|
registerProcess: vi.fn(),
|
||||||
|
closeStdin: vi.fn(),
|
||||||
|
notifyIdle: vi.fn(),
|
||||||
|
} as any,
|
||||||
|
getRegisteredGroups: () => ({ [chatJid]: group }),
|
||||||
|
getSessions: () => ({}),
|
||||||
|
getLastTimestamp: () => '',
|
||||||
|
setLastTimestamp: vi.fn(),
|
||||||
|
getLastAgentTimestamps: () => lastAgentTimestamps,
|
||||||
|
saveState,
|
||||||
|
persistSession: vi.fn(),
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.processGroupMessages(chatJid, {
|
||||||
|
runId: 'run-structured-silent-only',
|
||||||
|
reason: 'messages',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(channel.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(channel.sendAndTrack).not.toHaveBeenCalled();
|
||||||
|
expect(channel.setTyping).not.toHaveBeenCalledWith(chatJid, true);
|
||||||
|
});
|
||||||
|
|
||||||
it('defers typing-on until the first visible output for suppress-capable turns', async () => {
|
it('defers typing-on until the first visible output for suppress-capable turns', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group = makeGroup('claude-code');
|
const group = makeGroup('claude-code');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type AgentOutput } from './agent-runner.js';
|
import { type AgentOutput } from './agent-runner.js';
|
||||||
|
import { getAgentOutputText, isSilentAgentOutput } from './agent-output.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { classifySuppressTokenOutput } from './output-suppression.js';
|
import { classifySuppressTokenOutput } from './output-suppression.js';
|
||||||
import { formatOutbound } from './router.js';
|
import { formatOutbound } from './router.js';
|
||||||
@@ -124,15 +125,13 @@ export class MessageTurnController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw =
|
const raw = getAgentOutputText(result);
|
||||||
result.result === null || result.result === undefined
|
const silentOutput = isSilentAgentOutput(result);
|
||||||
? null
|
|
||||||
: typeof result.result === 'string'
|
|
||||||
? result.result
|
|
||||||
: JSON.stringify(result.result);
|
|
||||||
const suppressState =
|
const suppressState =
|
||||||
raw && this.options.suppressToken
|
raw && this.options.suppressToken
|
||||||
? classifySuppressTokenOutput(raw, this.options.suppressToken)
|
? classifySuppressTokenOutput(raw, this.options.suppressToken)
|
||||||
|
: raw
|
||||||
|
? classifySuppressTokenOutput(raw, undefined)
|
||||||
: 'none';
|
: 'none';
|
||||||
const text = raw && suppressState === 'none' ? formatOutbound(raw) : null;
|
const text = raw && suppressState === 'none' ? formatOutbound(raw) : null;
|
||||||
|
|
||||||
@@ -311,7 +310,7 @@ export class MessageTurnController {
|
|||||||
await this.finalizeProgressMessage();
|
await this.finalizeProgressMessage();
|
||||||
await this.deliverFinalText(text);
|
await this.deliverFinalText(text);
|
||||||
}
|
}
|
||||||
} else if (suppressState !== 'none') {
|
} else if (silentOutput || suppressState !== 'none') {
|
||||||
await this.finalizeProgressMessage();
|
await this.finalizeProgressMessage();
|
||||||
this.latestProgressTextForFinal = null;
|
this.latestProgressTextForFinal = null;
|
||||||
} else if (raw) {
|
} else if (raw) {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { classifySuppressTokenOutput } from './output-suppression.js';
|
import {
|
||||||
|
buildStructuredOutputPrompt,
|
||||||
|
classifySuppressTokenOutput,
|
||||||
|
parseStructuredOutputEnvelope,
|
||||||
|
} from './output-suppression.js';
|
||||||
|
|
||||||
describe('classifySuppressTokenOutput', () => {
|
describe('classifySuppressTokenOutput', () => {
|
||||||
it('treats the current turn suppress token as exact', () => {
|
it('treats the current turn suppress token as exact', () => {
|
||||||
@@ -38,4 +42,34 @@ describe('classifySuppressTokenOutput', () => {
|
|||||||
),
|
),
|
||||||
).toBe('mixed');
|
).toBe('mixed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats the exact structured silent envelope as exact silent output', () => {
|
||||||
|
expect(
|
||||||
|
classifySuppressTokenOutput('{"ejclaw":{"visibility":"silent"}}', undefined),
|
||||||
|
).toBe('exact');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseStructuredOutputEnvelope', () => {
|
||||||
|
it('parses the exact silent envelope', () => {
|
||||||
|
expect(
|
||||||
|
parseStructuredOutputEnvelope('{"ejclaw":{"visibility":"silent"}}'),
|
||||||
|
).toEqual({ visibility: 'silent' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a public envelope', () => {
|
||||||
|
expect(
|
||||||
|
parseStructuredOutputEnvelope(
|
||||||
|
'{"ejclaw":{"visibility":"public","text":"hello"}}',
|
||||||
|
),
|
||||||
|
).toEqual({ visibility: 'public', text: 'hello' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildStructuredOutputPrompt', () => {
|
||||||
|
it('prepends the structured output control block', () => {
|
||||||
|
expect(buildStructuredOutputPrompt('hello')).toContain(
|
||||||
|
'If you have no user-visible content to send for this turn, output exactly this JSON and nothing else: {"ejclaw":{"visibility":"silent"}}',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import {
|
|||||||
CODEX_REVIEW_SERVICE_ID,
|
CODEX_REVIEW_SERVICE_ID,
|
||||||
normalizeServiceId,
|
normalizeServiceId,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
|
import type { StructuredAgentOutput } from './types.js';
|
||||||
|
|
||||||
const ANY_SUPPRESS_TOKEN_PATTERN = /__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?/g;
|
const ANY_SUPPRESS_TOKEN_PATTERN = /__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?/g;
|
||||||
const EXACT_ANY_SUPPRESS_TOKEN_PATTERN = /^__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?$/;
|
const EXACT_ANY_SUPPRESS_TOKEN_PATTERN = /^__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?$/;
|
||||||
|
export const STRUCTURED_SILENT_OUTPUT_ENVELOPE =
|
||||||
|
'{"ejclaw":{"visibility":"silent"}}';
|
||||||
|
|
||||||
export function createSuppressToken(): string {
|
export function createSuppressToken(): string {
|
||||||
return `__EJ_SUPPRESS_${randomBytes(12).toString('hex')}__`;
|
return `__EJ_SUPPRESS_${randomBytes(12).toString('hex')}__`;
|
||||||
@@ -28,9 +31,11 @@ export function classifySuppressTokenOutput(
|
|||||||
suppressToken: string | undefined,
|
suppressToken: string | undefined,
|
||||||
): 'exact' | 'mixed' | 'none' {
|
): 'exact' | 'mixed' | 'none' {
|
||||||
const trimmed = rawText.trim();
|
const trimmed = rawText.trim();
|
||||||
|
const structured = parseStructuredOutputEnvelope(trimmed);
|
||||||
if (
|
if (
|
||||||
(suppressToken && trimmed === suppressToken) ||
|
(suppressToken && trimmed === suppressToken) ||
|
||||||
EXACT_ANY_SUPPRESS_TOKEN_PATTERN.test(trimmed)
|
EXACT_ANY_SUPPRESS_TOKEN_PATTERN.test(trimmed) ||
|
||||||
|
structured?.visibility === 'silent'
|
||||||
) {
|
) {
|
||||||
return 'exact';
|
return 'exact';
|
||||||
}
|
}
|
||||||
@@ -41,25 +46,50 @@ export function classifySuppressTokenOutput(
|
|||||||
return ANY_SUPPRESS_TOKEN_PATTERN.test(rawText) ? 'mixed' : 'none';
|
return ANY_SUPPRESS_TOKEN_PATTERN.test(rawText) ? 'mixed' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSuppressTokenPrompt(
|
export function parseStructuredOutputEnvelope(
|
||||||
|
rawText: string,
|
||||||
|
): StructuredAgentOutput | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawText) as {
|
||||||
|
ejclaw?: { visibility?: unknown; text?: unknown };
|
||||||
|
};
|
||||||
|
const envelope = parsed?.ejclaw;
|
||||||
|
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (envelope.visibility === 'silent') {
|
||||||
|
return { visibility: 'silent' };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
envelope.visibility === 'public' &&
|
||||||
|
typeof envelope.text === 'string' &&
|
||||||
|
envelope.text.length > 0
|
||||||
|
) {
|
||||||
|
return { visibility: 'public', text: envelope.text };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStructuredOutputPrompt(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
suppressToken: string | undefined,
|
|
||||||
options?: {
|
options?: {
|
||||||
reviewerMode?: boolean;
|
reviewerMode?: boolean;
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
if (!suppressToken) return prompt;
|
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
'[OUTPUT CONTROL]',
|
'[OUTPUT CONTROL]',
|
||||||
`If you have no user-visible content to send for this turn, output exactly this token and nothing else: ${suppressToken}`,
|
`If you have no user-visible content to send for this turn, output exactly this JSON and nothing else: ${STRUCTURED_SILENT_OUTPUT_ENVELOPE}`,
|
||||||
'Do not wrap the token in backticks or code fences.',
|
'Do not wrap the JSON in backticks or code fences.',
|
||||||
'Do not combine the token with any other text.',
|
'Do not combine the JSON with any other text.',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (options?.reviewerMode) {
|
if (options?.reviewerMode) {
|
||||||
lines.push(
|
lines.push(
|
||||||
'If you are only agreeing, mirroring, or restating without adding a concrete correction, risk, missing prerequisite, test gap, or code change, output only the token.',
|
'If you are only agreeing, mirroring, or restating without adding a concrete correction, risk, missing prerequisite, test gap, or code change, output only the JSON object.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { getAgentOutputText } from './agent-output.js';
|
||||||
import type { NewMessage } from './types.js';
|
import type { NewMessage } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { formatOutbound } from './router.js';
|
import { formatOutbound } from './router.js';
|
||||||
|
import type { StructuredAgentOutput } from './types.js';
|
||||||
|
|
||||||
const SESSION_COMMAND_CONTROL_PATTERNS = [
|
const SESSION_COMMAND_CONTROL_PATTERNS = [
|
||||||
/^Current session cleared\. The next message will start a new conversation\.$/,
|
/^Current session cleared\. The next message will start a new conversation\.$/,
|
||||||
@@ -48,6 +50,7 @@ export function isSessionCommandControlMessage(content: string): boolean {
|
|||||||
export interface AgentResult {
|
export interface AgentResult {
|
||||||
status: 'success' | 'error';
|
status: 'success' | 'error';
|
||||||
result?: string | object | null;
|
result?: string | object | null;
|
||||||
|
output?: StructuredAgentOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dependencies injected by the orchestrator. */
|
/** Dependencies injected by the orchestrator. */
|
||||||
@@ -73,6 +76,14 @@ function resultToText(result: string | object | null | undefined): string {
|
|||||||
return formatOutbound(raw);
|
return formatOutbound(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function agentResultToText(result: AgentResult): string {
|
||||||
|
const raw = getAgentOutputText({
|
||||||
|
result: result.result ?? null,
|
||||||
|
output: result.output,
|
||||||
|
});
|
||||||
|
return raw ? formatOutbound(raw) : '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle session command interception in processGroupMessages.
|
* Handle session command interception in processGroupMessages.
|
||||||
* Scans messages for a session command, handles auth + execution.
|
* Scans messages for a session command, handles auth + execution.
|
||||||
@@ -149,7 +160,7 @@ export async function handleSessionCommand(opts: {
|
|||||||
|
|
||||||
const preResult = await deps.runAgent(prePrompt, async (result) => {
|
const preResult = await deps.runAgent(prePrompt, async (result) => {
|
||||||
if (result.status === 'error') hadPreError = true;
|
if (result.status === 'error') hadPreError = true;
|
||||||
const text = resultToText(result.result);
|
const text = agentResultToText(result);
|
||||||
if (text) {
|
if (text) {
|
||||||
await deps.sendMessage(text);
|
await deps.sendMessage(text);
|
||||||
preOutputSent = true;
|
preOutputSent = true;
|
||||||
@@ -185,7 +196,7 @@ export async function handleSessionCommand(opts: {
|
|||||||
let hadCmdError = false;
|
let hadCmdError = false;
|
||||||
const cmdOutput = await deps.runAgent(command, async (result) => {
|
const cmdOutput = await deps.runAgent(command, async (result) => {
|
||||||
if (result.status === 'error') hadCmdError = true;
|
if (result.status === 'error') hadCmdError = true;
|
||||||
const text = resultToText(result.result);
|
const text = agentResultToText(result);
|
||||||
if (text) await deps.sendMessage(text);
|
if (text) await deps.sendMessage(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ChildProcess } from 'child_process';
|
import { ChildProcess } from 'child_process';
|
||||||
import { CronExpressionParser } from 'cron-parser';
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { getAgentOutputText } from './agent-output.js';
|
||||||
import { getErrorMessage } from './utils.js';
|
import { getErrorMessage } from './utils.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -398,9 +399,10 @@ async function runTask(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamedOutput.result) {
|
const outputText = getAgentOutputText(streamedOutput);
|
||||||
attemptResult = streamedOutput.result;
|
if (outputText) {
|
||||||
await deps.sendMessage(task.chat_jid, streamedOutput.result);
|
attemptResult = outputText;
|
||||||
|
await deps.sendMessage(task.chat_jid, outputText);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamedOutput.status === 'error') {
|
if (streamedOutput.status === 'error') {
|
||||||
@@ -412,8 +414,11 @@ async function runTask(
|
|||||||
|
|
||||||
if (output.status === 'error' && !attemptError) {
|
if (output.status === 'error' && !attemptError) {
|
||||||
attemptError = output.error || 'Unknown error';
|
attemptError = output.error || 'Unknown error';
|
||||||
} else if (output.result && !attemptResult) {
|
} else {
|
||||||
attemptResult = output.result;
|
const outputText = getAgentOutputText(output);
|
||||||
|
if (outputText && !attemptResult) {
|
||||||
|
attemptResult = outputText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
11
src/types.ts
11
src/types.ts
@@ -21,6 +21,17 @@ export type AgentOutputPhase =
|
|||||||
/** Phase as visible in the UI (mapped from AgentOutputPhase). */
|
/** Phase as visible in the UI (mapped from AgentOutputPhase). */
|
||||||
export type VisiblePhase = 'silent' | 'progress' | 'final';
|
export type VisiblePhase = 'silent' | 'progress' | 'final';
|
||||||
|
|
||||||
|
export type AgentVisibility = 'public' | 'silent';
|
||||||
|
|
||||||
|
export type StructuredAgentOutput =
|
||||||
|
| {
|
||||||
|
visibility: 'public';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
visibility: 'silent';
|
||||||
|
};
|
||||||
|
|
||||||
export function normalizeAgentOutputPhase(
|
export function normalizeAgentOutputPhase(
|
||||||
phase?: AgentOutputPhase,
|
phase?: AgentOutputPhase,
|
||||||
): AgentOutputPhase {
|
): AgentOutputPhase {
|
||||||
|
|||||||
Reference in New Issue
Block a user