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; 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,
}); });
} }

View File

@@ -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
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'; } 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;

View File

@@ -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),

View File

@@ -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 = {

View File

@@ -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');

View File

@@ -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) {

View File

@@ -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"}}',
);
});
}); });

View File

@@ -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.',
); );
} }

View File

@@ -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);
}); });

View File

@@ -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 {

View File

@@ -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 {