feat: add structured silent output contract
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
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';
|
||||
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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
11
src/types.ts
11
src/types.ts
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user