feat: add structured silent output contract

This commit is contained in:
Eyejoker
2026-03-28 05:56:58 +09:00
parent e1fdc47552
commit fe6314108c
13 changed files with 346 additions and 52 deletions

View File

@@ -40,6 +40,10 @@ interface ContainerOutput {
agentLabel?: string;
agentDone?: boolean;
result: string | null;
output?: {
visibility: 'public' | 'silent';
text?: string;
};
newSessionId?: string;
error?: string;
}
@@ -191,6 +195,48 @@ function writeOutput(output: ContainerOutput): void {
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 {
console.error(`[agent-runner] ${message}`);
}
@@ -715,10 +761,11 @@ async function runQuery(
const description = typeof tp.description === 'string' ? tp.description : '';
if (description && description.length <= 80) {
// Short tool description → show as sub-line in progress
const normalized = normalizeStructuredOutput(description);
writeOutput({
status: 'success',
phase: 'tool-activity',
result: description,
...normalized,
agentId: taskId,
newSessionId,
});
@@ -733,10 +780,11 @@ async function runQuery(
const desc = ts.description || '';
log(`Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`);
if (desc) {
const normalized = normalizeStructuredOutput(`🔄 ${desc}`);
writeOutput({
status: 'success',
phase: 'progress',
result: `🔄 ${desc}`,
...normalized,
agentId: ts.task_id,
agentLabel: desc,
newSessionId,
@@ -751,10 +799,11 @@ async function runQuery(
};
const label = `${tp.tool_name} (${Math.round(tp.elapsed_time_seconds)}s)`;
log(`Tool progress: ${label}`);
const normalized = normalizeStructuredOutput(label);
writeOutput({
status: 'success',
phase: 'progress',
result: label,
...normalized,
newSessionId,
});
}
@@ -762,10 +811,11 @@ async function runQuery(
if (message.type === 'tool_use_summary') {
const ts = message as { summary: string };
log(`Tool use summary: ${ts.summary.slice(0, 200)}`);
const normalized = normalizeStructuredOutput(ts.summary);
writeOutput({
status: 'success',
phase: 'progress',
result: ts.summary,
...normalized,
newSessionId,
});
}
@@ -806,9 +856,10 @@ async function runQuery(
error: errorText || `Agent error: ${message.subtype}`,
});
} else {
const normalized = normalizeStructuredOutput(textResult || null);
writeOutput({
status: 'success',
result: textResult || null,
...normalized,
newSessionId
});
}
@@ -837,7 +888,7 @@ async function runQuery(
);
writeOutput({
status: 'success',
result: textResult,
...normalizeStructuredOutput(textResult),
newSessionId,
});
terminalResultObserved = true;
@@ -852,10 +903,11 @@ async function runQuery(
if (stopReason !== 'end_turn' && textResult) {
// Flush previous pending as a regular message (not progress heading)
if (pendingProgressText) {
const normalized = normalizeStructuredOutput(pendingProgressText);
writeOutput({
status: 'success',
phase: 'intermediate',
result: pendingProgressText,
...normalized,
newSessionId,
});
}

View File

@@ -36,6 +36,10 @@ interface ContainerInput {
interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
output?: {
visibility: 'public' | 'silent';
text?: string;
};
phase?: 'progress' | 'final';
newSessionId?: string;
error?: string;
@@ -68,6 +72,48 @@ function writeOutput(output: ContainerOutput): void {
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 {
console.error(`[codex-runner] ${message}`);
}
@@ -206,7 +252,7 @@ async function executeAppServerTurn(
writeOutput({
status: 'success',
phase: 'progress',
result: trimmed,
...normalizeStructuredOutput(trimmed),
newSessionId: threadId,
});
},
@@ -351,17 +397,19 @@ async function runAppServerSession(
const { result, error } = await executeAppServerTurn(client, threadId, prompt);
if (error) {
const normalized = normalizeStructuredOutput(result || null);
log(`App-server turn error: ${error}`);
writeOutput({
status: 'error',
result: result || null,
...normalized,
newSessionId: threadId,
error,
});
} else {
const normalized = normalizeStructuredOutput(result || null);
writeOutput({
status: 'success',
result: result || null,
...normalized,
...(result ? { phase: 'final' as const } : {}),
newSessionId: threadId,
});

37
src/agent-output.ts Normal file
View 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';
}

View File

@@ -20,7 +20,12 @@ export {
} from './agent-runner-snapshot.js';
import { logger } from './logger.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 {
prompt: string;
@@ -40,6 +45,7 @@ export interface AgentInput {
export interface AgentOutput {
status: 'success' | 'error';
result: string | null;
output?: StructuredAgentOutput;
phase?: AgentOutputPhase;
agentId?: string;
agentLabel?: string;

View File

@@ -171,7 +171,7 @@ describe('runAgentForGroup room memory', () => {
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
group,
expect.objectContaining({
prompt: 'hello',
prompt: expect.stringContaining('hello'),
sessionId: undefined,
memoryBriefing: '## Shared Room Memory\n- remembered context',
}),
@@ -199,7 +199,7 @@ describe('runAgentForGroup room memory', () => {
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
group,
expect.objectContaining({
prompt: 'hello again',
prompt: expect.stringContaining('hello again'),
sessionId: 'session-existing',
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' };
await runAgentForGroup(makeDeps(), {
@@ -223,7 +223,7 @@ describe('runAgentForGroup room memory', () => {
group,
expect.objectContaining({
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),
@@ -254,7 +254,7 @@ describe('runAgentForGroup room memory', () => {
group,
expect.objectContaining({
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),

View File

@@ -1,5 +1,6 @@
import { getErrorMessage } from './utils.js';
import { getAgentOutputText } from './agent-output.js';
import {
AgentOutput,
runAgentProcess,
@@ -26,7 +27,7 @@ import {
SERVICE_SESSION_SCOPE,
} from './config.js';
import {
buildSuppressTokenPrompt,
buildStructuredOutputPrompt,
classifySuppressTokenOutput,
} from './output-suppression.js';
import {
@@ -120,7 +121,7 @@ export async function runAgentForGroup(
const currentLease = getEffectiveChannelLease(chatJid);
const reviewerMode =
currentLease.reviewer_service_id === SERVICE_SESSION_SCOPE;
const effectivePrompt = buildSuppressTokenPrompt(prompt, suppressToken, {
const effectivePrompt = buildStructuredOutputPrompt(prompt, {
reviewerMode,
});
@@ -229,9 +230,10 @@ export async function runAgentForGroup(
});
streamedState = evaluation.state;
const outputText = getAgentOutputText(output);
if (
evaluation.newTrigger &&
typeof output.result === 'string' &&
typeof outputText === 'string' &&
output.status === 'success'
) {
logger.warn(
@@ -240,7 +242,7 @@ export async function runAgentForGroup(
group: group.name,
runId,
reason: evaluation.newTrigger.reason,
resultPreview: output.result.slice(0, 120),
resultPreview: outputText.slice(0, 120),
},
'Detected Claude rotation trigger in successful output',
);
@@ -269,8 +271,8 @@ export async function runAgentForGroup(
group: group.name,
runId,
resultPreview:
typeof output.result === 'string'
? output.result.slice(0, 120)
typeof outputText === 'string'
? outputText.slice(0, 120)
: undefined,
},
'Suppressed Claude 401 auth error from chat output',
@@ -285,8 +287,8 @@ export async function runAgentForGroup(
group: group.name,
runId,
resultPreview:
typeof output.result === 'string'
? output.result.slice(0, 160)
typeof outputText === 'string'
? outputText.slice(0, 160)
: output.error?.slice(0, 160),
},
'Suppressed retryable Claude session failure from chat output',
@@ -298,12 +300,12 @@ export async function runAgentForGroup(
return;
}
const suppressState =
typeof output.result === 'string'
? classifySuppressTokenOutput(output.result, suppressToken)
typeof outputText === 'string'
? classifySuppressTokenOutput(outputText, suppressToken)
: 'none';
if (
typeof output.result === 'string' &&
output.result.length > 0 &&
typeof outputText === 'string' &&
outputText.length > 0 &&
suppressState === 'none'
) {
streamedState = {

View File

@@ -1424,6 +1424,65 @@ describe('createMessageRuntime', () => {
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 () => {
const chatJid = 'group@test';
const group = makeGroup('claude-code');

View File

@@ -1,4 +1,5 @@
import { type AgentOutput } from './agent-runner.js';
import { getAgentOutputText, isSilentAgentOutput } from './agent-output.js';
import { logger } from './logger.js';
import { classifySuppressTokenOutput } from './output-suppression.js';
import { formatOutbound } from './router.js';
@@ -124,15 +125,13 @@ export class MessageTurnController {
);
}
const raw =
result.result === null || result.result === undefined
? null
: typeof result.result === 'string'
? result.result
: JSON.stringify(result.result);
const raw = getAgentOutputText(result);
const silentOutput = isSilentAgentOutput(result);
const suppressState =
raw && this.options.suppressToken
? classifySuppressTokenOutput(raw, this.options.suppressToken)
: raw
? classifySuppressTokenOutput(raw, undefined)
: 'none';
const text = raw && suppressState === 'none' ? formatOutbound(raw) : null;
@@ -311,7 +310,7 @@ export class MessageTurnController {
await this.finalizeProgressMessage();
await this.deliverFinalText(text);
}
} else if (suppressState !== 'none') {
} else if (silentOutput || suppressState !== 'none') {
await this.finalizeProgressMessage();
this.latestProgressTextForFinal = null;
} else if (raw) {

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import { classifySuppressTokenOutput } from './output-suppression.js';
import {
buildStructuredOutputPrompt,
classifySuppressTokenOutput,
parseStructuredOutputEnvelope,
} from './output-suppression.js';
describe('classifySuppressTokenOutput', () => {
it('treats the current turn suppress token as exact', () => {
@@ -38,4 +42,34 @@ describe('classifySuppressTokenOutput', () => {
),
).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"}}',
);
});
});

View File

@@ -5,9 +5,12 @@ import {
CODEX_REVIEW_SERVICE_ID,
normalizeServiceId,
} from './config.js';
import type { StructuredAgentOutput } from './types.js';
const ANY_SUPPRESS_TOKEN_PATTERN = /__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?/g;
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 {
return `__EJ_SUPPRESS_${randomBytes(12).toString('hex')}__`;
@@ -28,9 +31,11 @@ export function classifySuppressTokenOutput(
suppressToken: string | undefined,
): 'exact' | 'mixed' | 'none' {
const trimmed = rawText.trim();
const structured = parseStructuredOutputEnvelope(trimmed);
if (
(suppressToken && trimmed === suppressToken) ||
EXACT_ANY_SUPPRESS_TOKEN_PATTERN.test(trimmed)
EXACT_ANY_SUPPRESS_TOKEN_PATTERN.test(trimmed) ||
structured?.visibility === 'silent'
) {
return 'exact';
}
@@ -41,25 +46,50 @@ export function classifySuppressTokenOutput(
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,
suppressToken: string | undefined,
options?: {
reviewerMode?: boolean;
},
): string {
if (!suppressToken) return prompt;
const lines = [
'[OUTPUT CONTROL]',
`If you have no user-visible content to send for this turn, output exactly this token and nothing else: ${suppressToken}`,
'Do not wrap the token in backticks or code fences.',
'Do not combine the token with any other text.',
`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 JSON in backticks or code fences.',
'Do not combine the JSON with any other text.',
];
if (options?.reviewerMode) {
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.',
);
}

View File

@@ -1,6 +1,8 @@
import { getAgentOutputText } from './agent-output.js';
import type { NewMessage } from './types.js';
import { logger } from './logger.js';
import { formatOutbound } from './router.js';
import type { StructuredAgentOutput } from './types.js';
const SESSION_COMMAND_CONTROL_PATTERNS = [
/^Current session cleared\. The next message will start a new conversation\.$/,
@@ -48,6 +50,7 @@ export function isSessionCommandControlMessage(content: string): boolean {
export interface AgentResult {
status: 'success' | 'error';
result?: string | object | null;
output?: StructuredAgentOutput;
}
/** Dependencies injected by the orchestrator. */
@@ -73,6 +76,14 @@ function resultToText(result: string | object | null | undefined): string {
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.
* 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) => {
if (result.status === 'error') hadPreError = true;
const text = resultToText(result.result);
const text = agentResultToText(result);
if (text) {
await deps.sendMessage(text);
preOutputSent = true;
@@ -185,7 +196,7 @@ export async function handleSessionCommand(opts: {
let hadCmdError = false;
const cmdOutput = await deps.runAgent(command, async (result) => {
if (result.status === 'error') hadCmdError = true;
const text = resultToText(result.result);
const text = agentResultToText(result);
if (text) await deps.sendMessage(text);
});

View File

@@ -1,6 +1,7 @@
import { ChildProcess } from 'child_process';
import { CronExpressionParser } from 'cron-parser';
import fs from 'fs';
import { getAgentOutputText } from './agent-output.js';
import { getErrorMessage } from './utils.js';
import {
@@ -398,9 +399,10 @@ async function runTask(
return;
}
if (streamedOutput.result) {
attemptResult = streamedOutput.result;
await deps.sendMessage(task.chat_jid, streamedOutput.result);
const outputText = getAgentOutputText(streamedOutput);
if (outputText) {
attemptResult = outputText;
await deps.sendMessage(task.chat_jid, outputText);
}
if (streamedOutput.status === 'error') {
@@ -412,8 +414,11 @@ async function runTask(
if (output.status === 'error' && !attemptError) {
attemptError = output.error || 'Unknown error';
} else if (output.result && !attemptResult) {
attemptResult = output.result;
} else {
const outputText = getAgentOutputText(output);
if (outputText && !attemptResult) {
attemptResult = outputText;
}
}
return {

View File

@@ -21,6 +21,17 @@ export type AgentOutputPhase =
/** Phase as visible in the UI (mapped from AgentOutputPhase). */
export type VisiblePhase = 'silent' | 'progress' | 'final';
export type AgentVisibility = 'public' | 'silent';
export type StructuredAgentOutput =
| {
visibility: 'public';
text: string;
}
| {
visibility: 'silent';
};
export function normalizeAgentOutputPhase(
phase?: AgentOutputPhase,
): AgentOutputPhase {