diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index a3a2b9f..a806117 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -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, }); } diff --git a/runners/codex-runner/src/index.ts b/runners/codex-runner/src/index.ts index e170fee..b45183a 100644 --- a/runners/codex-runner/src/index.ts +++ b/runners/codex-runner/src/index.ts @@ -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, }); diff --git a/src/agent-output.ts b/src/agent-output.ts new file mode 100644 index 0000000..8da059d --- /dev/null +++ b/src/agent-output.ts @@ -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'; +} diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 45b90f5..6b1e5ae 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -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; diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 03ded9b..70625a7 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -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), diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index ffec1e3..a283c81 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -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 = { diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 4c0ea11..52d3c34 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -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 = {}; + 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'); diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index bf46f61..3488abd 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -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) { diff --git a/src/output-suppression.test.ts b/src/output-suppression.test.ts index 1cf0549..01bc620 100644 --- a/src/output-suppression.test.ts +++ b/src/output-suppression.test.ts @@ -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"}}', + ); + }); }); diff --git a/src/output-suppression.ts b/src/output-suppression.ts index c9e5d08..efeccab 100644 --- a/src/output-suppression.ts +++ b/src/output-suppression.ts @@ -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.', ); } diff --git a/src/session-commands.ts b/src/session-commands.ts index 946aa6d..50c38df 100644 --- a/src/session-commands.ts +++ b/src/session-commands.ts @@ -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); }); diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index c534fc0..54833c1 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -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 { diff --git a/src/types.ts b/src/types.ts index 7ba8997..686cac4 100644 --- a/src/types.ts +++ b/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 {