diff --git a/middleware/packages/harness-channel-sdk/src/chatAgent.ts b/middleware/packages/harness-channel-sdk/src/chatAgent.ts index 86ee8fbe..19e28efa 100644 --- a/middleware/packages/harness-channel-sdk/src/chatAgent.ts +++ b/middleware/packages/harness-channel-sdk/src/chatAgent.ts @@ -1,5 +1,9 @@ import type { PrivacyReceipt, RecalledContext } from '@omadia/plugin-api'; -import type { FollowUpOption, SemanticAnswer } from './outgoing.js'; +import type { + DelegatedAnswer, + FollowUpOption, + SemanticAnswer, +} from './outgoing.js'; import type { SurfaceStreamEvent, PendingCanvasSurface } from './surface.js'; /** @@ -229,6 +233,18 @@ export interface ChatTurnInput { * need it simply omit. */ extraSystemHint?: string; + /** + * #332 Layer 3 — forced-delegation obligation. The tool name of a sub-agent + * that MUST be invoked at least once during this turn. If the orchestrator + * tries to end the turn (pure-text stop) without ever calling it, the harness + * performs ONE bounded escalation iteration with + * `tool_choice: { type: 'tool', name: }` plus a synthetic reminder — + * exactly as `LocalSubAgent` does for sub-agents (OB-31). Unknown tool names + * are ignored. The Conductor (Spec 005) composes these into multi-step + * processes; on its own it guarantees the consult HAPPENS (Layer 1 surfaces + * it). Faithful input/output is the Direct Line's (Layer 2) job. + */ + expectedDomainTool?: string; /** * "Fresh check" mode — bypass the FTS context block, verbatim tail, and * memory-read convention for this turn only. The orchestrator treats the @@ -428,6 +444,14 @@ export interface ChatTurnResult { * Omitted when nothing was recalled. */ recalled?: RecalledContext; + /** + * #332 Layer 2 — Direct Line. The verbatim sub-agent answer for a turn the + * user directed at a named specialist (`@omadia #strategist …`). Set by the + * harness inside `chatStream`, NOT by the LLM; `toSemanticAnswer` forwards it + * to `SemanticAnswer.delegatedAnswer` so the orchestrator cannot suppress or + * reword it. Omitted on ordinary turns. + */ + delegatedAnswer?: DelegatedAnswer; } /** @@ -622,6 +646,12 @@ export type ChatStreamEvent = * answered, alongside the live token counts. */ model?: string; + /** + * #332 Layer 2 — Direct Line. Harness-owned verbatim sub-agent segment + * for a user-directed specialist turn; see ChatTurnResult.delegatedAnswer. + * The orchestrator cannot suppress or reword it. + */ + delegatedAnswer?: DelegatedAnswer; } /** * Emitted after `done` by the verifier wrapper (only when enabled). The diff --git a/middleware/packages/harness-channel-sdk/src/index.ts b/middleware/packages/harness-channel-sdk/src/index.ts index d2d8075b..99f43dc6 100644 --- a/middleware/packages/harness-channel-sdk/src/index.ts +++ b/middleware/packages/harness-channel-sdk/src/index.ts @@ -98,6 +98,10 @@ export type { // adapters can call it without crossing into kernel internals. export { toSemanticAnswer } from './toSemanticAnswer.js'; +// #332 Layer 1 — plain-text fallback so even a minimal connector (no rich-card +// UI) can append a readable, harness-sourced consulted-agents footer line. +export { agentsConsultedFooterText } from './toSemanticAnswer.js'; + // Semantic outgoing-message contracts (connectors render native) export type { SemanticAnswer, @@ -109,6 +113,8 @@ export type { OutgoingSlotPicker, OutgoingTopicAsk, CaptureDisclosure, + AgentConsultation, + DelegatedAnswer, } from './outgoing.js'; // Channel-agnostic store interfaces diff --git a/middleware/packages/harness-channel-sdk/src/outgoing.ts b/middleware/packages/harness-channel-sdk/src/outgoing.ts index 103d8eba..c89ef8f0 100644 --- a/middleware/packages/harness-channel-sdk/src/outgoing.ts +++ b/middleware/packages/harness-channel-sdk/src/outgoing.ts @@ -109,6 +109,64 @@ export interface SemanticAnswer { * recalled. Sidecar — does NOT short-circuit the answer. */ recalled?: RecalledContext; + + /** + * #332 Layer 1 — tamper-evident agent transparency. A curated projection of + * the deterministic run-trace's sub-agent invocations, built by the HARNESS + * (not the LLM) from the choke-point trace. Lets EVERY channel — including + * Teams / Telegram, which never see the raw `runTrace` — show which + * specialist(s) were actually consulted this turn. If the orchestrator only + * *claims* "I asked the Strategist" but never invoked the tool, this array is + * empty and the contradiction is visible. Omitted when no sub-agent ran. + * Sidecar — does NOT short-circuit the answer. + */ + agentsConsulted?: readonly AgentConsultation[]; + + /** + * #332 Layer 2 — Direct Line. The verbatim answer of a sub-agent the USER + * directed input at (e.g. `@omadia #strategist …`), captured at the choke + * point and delivered as a HARNESS-owned, attributed segment INDEPENDENT of + * the orchestrator's own `text`. The orchestrator can neither remove nor + * rewrite it — its only sanctioned addition is an attributed, additive note + * in `text` (never a replacement). Still PII-masked by the privacy guard. + * Omitted on ordinary turns (no direct-line directive). Sidecar. + */ + delegatedAnswer?: DelegatedAnswer; +} + +/** + * #332 Layer 1 — one curated entry per sub-agent invocation this turn. + * Derived from the deterministic `runTrace.agentInvocations` (the choke-point + * record), NEVER from the orchestrator's prose. Carries only what a footer + * needs; the raw run-trace stays behind the connector boundary. + */ +export interface AgentConsultation { + /** Stable agent id when resolvable (e.g. `de.byte5.agent.strategist`). */ + agentId?: string; + /** Human label for the footer (e.g. `Strategist`). Always present. */ + label: string; + /** Deterministic outcome of the invocation. */ + status: 'success' | 'error'; + /** Wall-clock duration of the invocation, when recorded. */ + durationMs?: number; + /** COUNT of tool calls the sub-agent made — never the orchestrator's prose. */ + toolCalls?: number; +} + +/** + * #332 Layer 2 — the harness-owned verbatim sub-agent segment for a + * direct-line turn. Rendered attributed and visually separate from the + * orchestrator's `text`. `status: 'error'` carries a faithful failure message + * in `text` (never a cover-up or hallucinated answer). + */ +export interface DelegatedAnswer { + /** Stable agent id the directive resolved to. */ + agentId: string; + /** Human label for attribution (e.g. `Strategist`). */ + label: string; + /** The sub-agent's verbatim answer (PII-masked), or a faithful error line. */ + text: string; + status: 'success' | 'error'; } /** Image/file side-channel. `url` must be reachable by the channel. */ diff --git a/middleware/packages/harness-channel-sdk/src/toSemanticAnswer.ts b/middleware/packages/harness-channel-sdk/src/toSemanticAnswer.ts index aec0a657..dba353a5 100644 --- a/middleware/packages/harness-channel-sdk/src/toSemanticAnswer.ts +++ b/middleware/packages/harness-channel-sdk/src/toSemanticAnswer.ts @@ -1,11 +1,54 @@ import type { ChatTurnResult } from './chatAgent.js'; import type { + AgentConsultation, OutgoingAttachment, OutgoingInteractive, SemanticAnswer, VerifierBadge, } from './outgoing.js'; +/** + * #332 Layer 1 — plain-text fallback footer for connectors without rich-card + * UI. Renders the harness-sourced `agentsConsulted` projection as a single + * readable line, e.g. `🔎 Consulted: Strategist ✓ · 2 steps`. Returns + * `undefined` when no sub-agent ran (caller appends nothing). Rich connectors + * (web-ui, Teams) render their own UI from the structured field instead. + */ +export function agentsConsultedFooterText( + answer: Pick, +): string | undefined { + const consulted = answer.agentsConsulted; + if (!consulted || consulted.length === 0) return undefined; + const parts = consulted.map((c) => { + const mark = c.status === 'success' ? '✓' : '✗'; + const steps = + typeof c.toolCalls === 'number' && c.toolCalls > 0 + ? ` · ${c.toolCalls} ${c.toolCalls === 1 ? 'step' : 'steps'}` + : ''; + return `${c.label} ${mark}${steps}`; + }); + return `🔎 Consulted: ${parts.join(' · ')}`; +} + +/** + * Humanize a run-trace `agentName` (which is the invoked tool name, e.g. + * `ask_strategist` / `@omadia/agent-strategist`) into a footer label. Mirrors + * the middleware-side `labelFromAgentId` (agents/resolveAgentForTool.ts) but + * stays local — channel-sdk has no access to the dynamic agent runtime. Strips + * an `ask_`/`consult_` verb prefix and any npm-scope / legacy-namespace, then + * Title-Cases the remainder. + */ +function humanizeAgentLabel(agentName: string): string { + const last = agentName.split(/[./]/).pop() ?? agentName; + const deverbed = last.replace(/^(?:ask|consult|query|invoke|agent)[-_]/i, ''); + const titled = deverbed + .split(/[-_]/) + .filter((seg) => seg.length > 0) + .map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1)) + .join(' '); + return titled.length > 0 ? titled : agentName; +} + /** * Convert the internal kernel-shaped `ChatTurnResult` to the channel-agnostic * `SemanticAnswer` contract. Observability-only fields (runTrace, toolCalls, @@ -87,9 +130,30 @@ export function toSemanticAnswer(r: ChatTurnResult): SemanticAnswer { ? { status: r.verifier.badge } : undefined; + // #332 Layer 1 — curate a tamper-evident consulted-agents footer from the + // deterministic run-trace. This is the ONLY sub-agent signal Teams/Telegram + // get; the raw `runTrace` stays dropped (see header). Built by the harness, + // outside the LLM's output stream — a fabricated "I asked X" with no real + // invocation yields an empty array here. + const agentsConsulted: AgentConsultation[] | undefined = + r.runTrace?.agentInvocations && r.runTrace.agentInvocations.length > 0 + ? r.runTrace.agentInvocations.map((inv) => ({ + label: humanizeAgentLabel(inv.agentName), + status: inv.status, + ...(typeof inv.durationMs === 'number' + ? { durationMs: inv.durationMs } + : {}), + ...(inv.toolCalls ? { toolCalls: inv.toolCalls.length } : {}), + })) + : undefined; + return { text: r.answer, ...(verifier ? { verifier } : {}), + ...(agentsConsulted && agentsConsulted.length > 0 + ? { agentsConsulted } + : {}), + ...(r.delegatedAnswer ? { delegatedAnswer: r.delegatedAnswer } : {}), ...(attachments && attachments.length > 0 ? { attachments } : {}), ...(r.followUpOptions && r.followUpOptions.length > 0 ? { followUps: r.followUpOptions } diff --git a/middleware/packages/harness-orchestrator/src/directLine.ts b/middleware/packages/harness-orchestrator/src/directLine.ts new file mode 100644 index 00000000..acab787c --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/directLine.ts @@ -0,0 +1,131 @@ +/** + * #332 Layer 2 — Direct Line directive parsing & target resolution. + * + * Pure, channel-agnostic helpers. The orchestrator owns the turn as always; + * when the USER names a specialist inside the payload (`@omadia #strategist + * `), these helpers let the HARNESS — not the LLM — bind the + * sub-agent's input to the verbatim user payload and route it through the + * deterministic choke point. No model, no orchestrator discretion. + * + * Channel survival: chat clients resolve their own `@`-mentions, so the bot is + * addressed natively and the specialist is named *in the payload*. The Teams + * adapter additionally strips the bot's recipient mention and collapses + * whitespace (`extractUserMessage`), so the directive must be a single leading + * token that survives `\s+`→space normalization. A leading `#