Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion middleware/packages/harness-channel-sdk/src/chatAgent.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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: <X> }` 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
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions middleware/packages/harness-channel-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -109,6 +113,8 @@ export type {
OutgoingSlotPicker,
OutgoingTopicAsk,
CaptureDisclosure,
AgentConsultation,
DelegatedAnswer,
} from './outgoing.js';

// Channel-agnostic store interfaces
Expand Down
58 changes: 58 additions & 0 deletions middleware/packages/harness-channel-sdk/src/outgoing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
64 changes: 64 additions & 0 deletions middleware/packages/harness-channel-sdk/src/toSemanticAnswer.ts
Original file line number Diff line number Diff line change
@@ -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<SemanticAnswer, 'agentsConsulted'>,
): 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,
Expand Down Expand Up @@ -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 }
Expand Down
131 changes: 131 additions & 0 deletions middleware/packages/harness-orchestrator/src/directLine.ts
Original file line number Diff line number Diff line change
@@ -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
* <question>`), 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 `#<label>` token
* meets that bar.
*/

/** A parsed direct-line directive. */
export interface DirectLineDirective {
/** The bare token the user named, lower-cased, sans prefix (e.g. `strategist`). */
token: string;
/** The verbatim remainder = the faithful sub-agent input. */
payload: string;
}

/** Default directive prefix. Configurable per deployment (see Open question 3). */
export const DEFAULT_DIRECTIVE_PREFIX = '#';

/**
* Parse a leading `#<token> <payload>` directive out of a user message.
*
* Hard requirements honoured:
* - The directive token must be the FIRST token (after trim) — this both
* survives Teams whitespace-collapse and gives a clean literal-collision
* rule: a `#` that is NOT the first token is treated as ordinary text.
* - Returns `undefined` when there is no leading directive (ordinary turn).
* - Returns a directive with an EMPTY payload when the user named a specialist
* but typed nothing after it — the caller surfaces a faithful prompt rather
* than dispatching an empty question.
*/
export function parseDirectLineDirective(
text: string,
prefix: string = DEFAULT_DIRECTIVE_PREFIX,
): DirectLineDirective | undefined {
const trimmed = text.trimStart();
if (!trimmed.startsWith(prefix)) return undefined;
const afterPrefix = trimmed.slice(prefix.length);
// Token = leading run of label characters (letters, digits, _ , -, .).
const match = /^([A-Za-z0-9._-]+)(.*)$/s.exec(afterPrefix);
if (!match) return undefined;
const token = match[1]!.toLowerCase();
// Strip ONLY the leading separator whitespace between token and payload;
// keep the remainder byte-for-byte so a whitespace-significant payload (e.g.
// a fenced code block) reaches the sub-agent verbatim. An all-whitespace
// remainder collapses to '' (treated as an empty payload by the caller).
const payload = match[2]!.replace(/^\s+/, '');
return { token, payload };
}

/** A candidate sub-agent the directive may resolve to. */
export interface DirectLineCandidate {
/** Stable tool name (key in the orchestrator's whitelist map). */
toolName: string;
/** Stable agent id when known (e.g. `de.byte5.agent.strategist`). */
agentId?: string;
/** Human label for attribution (e.g. `Strategist`). */
label: string;
}

/** Outcome of resolving a directive token against the whitelisted agents. */
export type DirectLineResolution =
| { kind: 'resolved'; candidate: DirectLineCandidate }
| { kind: 'unknown' }
| { kind: 'ambiguous'; matches: DirectLineCandidate[] };

/**
* Human label for a sub-agent, derived from its stable agent id (preferred)
* or tool name. Mirrors the middleware `labelFromAgentId` (Title-Cased last
* segment) and strips an `ask_`/`consult_` verb prefix from tool names.
*/
export function directLineLabel(agentIdOrToolName: string): string {
const last = agentIdOrToolName.split(/[./]/).pop() ?? agentIdOrToolName;
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 : agentIdOrToolName;
}

/** Reduce a label/id/tool-name to a comparable key (lowercase alphanumerics). */
function normalizeKey(value: string): string {
const last = value.split(/[./]/).pop() ?? value;
const deverbed = last.replace(/^(?:ask|consult|query|invoke|agent)[-_]/i, '');
return deverbed.toLowerCase().replace(/[^a-z0-9]/g, '');
}

/**
* Resolve a directive token to exactly one whitelisted sub-agent — or report
* `unknown` / `ambiguous` so the caller can surface a disambiguation prompt and
* NEVER silently route to the wrong agent (Pitfall 7). Matching is scoped to
* the candidates the caller passes, which MUST be this orchestrator's
* whitelisted sub-agents (reuses the OB-29-1 access gating).
*/
export function resolveDirectLineTarget(
token: string,
candidates: readonly DirectLineCandidate[],
): DirectLineResolution {
const want = normalizeKey(token);
if (want.length === 0) return { kind: 'unknown' };
const matches = candidates.filter((c) => {
const keys = [normalizeKey(c.label), normalizeKey(c.toolName)];
if (c.agentId) keys.push(normalizeKey(c.agentId));
return keys.includes(want);
});
if (matches.length === 0) return { kind: 'unknown' };
if (matches.length > 1) return { kind: 'ambiguous', matches };
return { kind: 'resolved', candidate: matches[0]! };
}

/** Direct-line delivery policy — who, if anyone, may add to the verbatim block. */
export type DirectLineMode =
/** Pure relay: the orchestrator's own generation is suppressed. Default. */
| 'strict'
/**
* Guarded additive: the verbatim sub-agent answer is still delivered
* byte-for-byte and independently (harness-owned), but the orchestrator MAY
* append an attributed, visually separated note. It can never redact.
*/
| 'guarded';
15 changes: 15 additions & 0 deletions middleware/packages/harness-orchestrator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,21 @@ export type {
export { Orchestrator, parseToolEmittedChoice } from './orchestrator.js';
export type { OrchestratorOptions } from './orchestrator.js';

// #332 Layer 2 — Direct Line directive parsing & target resolution (exported
// for unit coverage and reuse by deterministic routers / the Conductor).
export {
parseDirectLineDirective,
resolveDirectLineTarget,
directLineLabel,
DEFAULT_DIRECTIVE_PREFIX,
} from './directLine.js';
export type {
DirectLineDirective,
DirectLineCandidate,
DirectLineResolution,
DirectLineMode,
} from './directLine.js';

// Round-loop guard — exported so it can be unit-tested in isolation and reused
// by other agentic loops (e.g. the Builder).
export { LoopGuard, canonicalize } from './loopGuard.js';
Expand Down
Loading
Loading