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
7 changes: 7 additions & 0 deletions middleware/packages/harness-channel-sdk/src/chatAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ export interface RunToolCall {
/** External ids of entities produced by this call (odoo://…, confluence://…).
* Wired by the entity-ref bus, same source as `TurnIngest.entityRefs`. */
producedEntityIds?: string[];
/** #130 — set when the bridge ran an optional output Zod schema on the
* tool's return value and it failed. Structural copy of KG-side
* `RunToolCall.postcondition`; the verifier reads this to raise a
* `tool_postcondition` claim. */
postcondition?: {
issues: readonly string[];
};
}

/** Per-sub-agent invocation entry in a run trace. */
Expand Down
6 changes: 5 additions & 1 deletion middleware/packages/harness-orchestrator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ export { VerifierService } from './verifierService.js';

// Sub-agent runtime
export { LocalSubAgent } from './localSubAgent.js';
export type { LocalSubAgentTool, AskOptions } from './localSubAgent.js';
export type {
LocalSubAgentTool,
LocalSubAgentToolResult,
AskOptions,
} from './localSubAgent.js';

// Knowledge-graph native tool (moved from harness-knowledge-graph in S+12.5-1)
export {
Expand Down
39 changes: 30 additions & 9 deletions middleware/packages/harness-orchestrator/src/localSubAgent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type Anthropic from '@anthropic-ai/sdk';
import type {
LocalSubAgentTool,
LocalSubAgentToolResult,
LocalSubAgentToolSpec,
} from '@omadia/plugin-api';
import { streamMessageWithObserver } from './streaming.js';
Expand All @@ -13,7 +14,11 @@ import { buildDateHeader, turnContext } from './turnContext.js';
// can produce values of those shapes without reaching back into kernel
// source. Re-exported for kernel-internal consumers that previously
// imported from `./localSubAgent.js`.
export type { LocalSubAgentTool, LocalSubAgentToolSpec };
export type {
LocalSubAgentTool,
LocalSubAgentToolResult,
LocalSubAgentToolSpec,
};

interface LocalSubAgentOptions {
/** Label used in logs — typically the domain, e.g. `odoo-hr`. */
Expand Down Expand Up @@ -351,9 +356,12 @@ export class LocalSubAgent {
console.warn(`[sub-agent ${this.name}] observer.onSubToolUse threw:`, err);
}
const started = Date.now();
const output = await this.dispatch(use.name, use.input);
const { output, postcondition } = await this.dispatch(
use.name,
use.input,
);
const elapsed = Date.now() - started;
const isError = output.startsWith('Error:');
const isError = output.startsWith('Error:') || postcondition !== undefined;
console.log(
`[sub-agent ${this.name}] ${String(use.name)} ${isError ? '→ ERR' : '→ ok'} (${String(elapsed)}ms, ${String(output.length)} chars)`,
);
Expand All @@ -363,6 +371,7 @@ export class LocalSubAgent {
output,
durationMs: elapsed,
isError,
...(postcondition ? { postcondition } : {}),
});
} catch (err) {
console.warn(`[sub-agent ${this.name}] observer.onSubToolResult threw:`, err);
Expand Down Expand Up @@ -431,16 +440,25 @@ export class LocalSubAgent {
}
}

private async dispatch(toolName: string, input: unknown): Promise<string> {
private async dispatch(
toolName: string,
input: unknown,
): Promise<{ output: string; postcondition?: { issues: readonly string[] } }> {
const tool = this.toolsByName.get(toolName);
if (!tool) return `Error: unknown tool \`${toolName}\`.`;
if (!tool) return { output: `Error: unknown tool \`${toolName}\`.` };

// Privacy Shield v4 — Data-Plane Boundary for sub-agent inner calls.
// The privacy handle is threaded through `turnContext.privacyHandle`;
// sub-agents inherit it from the parent orchestrator's turn scope.
// Absent ⇒ no privacy provider installed and the result flows through.
const privacy = turnContext.current()?.privacyHandle;
const result = await tool.handle(input);
// #130 — unwrap the structured tool-result union at the boundary so
// every privacy / capture path downstream keeps seeing a plain string,
// while we still surface the optional postcondition marker upward to
// the observer (which the RunTraceCollector copies onto the trace).
const raw = await tool.handle(input);
const result = typeof raw === 'string' ? raw : raw.output;
const postcondition = typeof raw === 'string' ? undefined : raw.postcondition;
// Phase C.2 — Raw tool-result capture (parallel to orchestrator.dispatchTool).
// Sub-agent tool calls also feed routine templates, so the capture
// hook must fire here too. Absent callback ⇒ no capture.
Expand Down Expand Up @@ -478,7 +496,7 @@ export class LocalSubAgent {
err,
);
}
return result;
return { output: result, ...(postcondition ? { postcondition } : {}) };
}
// Intern the raw result server-side and hand the LLM only the
// identity-free digest — the raw rows never reach the LLM wire.
Expand All @@ -493,15 +511,18 @@ export class LocalSubAgent {
// installed by the parent's dispatchTool scope; absent ⇒ this
// sub-agent is not running under a domain-tool bridge.
turnContext.current()?.subAgentDatasetSink?.push(v4.datasetId);
return v4.digestText;
return {
output: v4.digestText,
...(postcondition ? { postcondition } : {}),
};
} catch (err) {
console.warn(
`[sub-agent ${this.name}] privacy.internToolResultV4 threw on '${toolName}' — sending raw result:`,
err,
);
}
}
return result;
return { output: result, ...(postcondition ? { postcondition } : {}) };
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export class RunTraceCollector {
durationMs: ev.durationMs,
isError: ev.isError,
agentContext: agentName,
...(ev.postcondition ? { postcondition: ev.postcondition } : {}),
});
toolCallStarts.delete(ev.id);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export interface AskObserver {
output: string;
durationMs: number;
isError: boolean;
/** #130 — present when the bridge ran an optional output Zod schema
* on the tool's return value and it failed. RunTraceCollector copies
* the issues onto the RunToolCall so the verifier can pick them up. */
postcondition?: {
issues: readonly string[];
};
}): void;
onIterationPhase?(ev: {
iteration: number;
Expand Down
50 changes: 50 additions & 0 deletions middleware/packages/harness-orchestrator/src/verifierService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,12 +228,16 @@ export class VerifierService implements ChatAgent {
runTrace: RunTracePayload | undefined,
): Promise<VerifierVerdict> {
const domainToolsCalled = extractToolsCalled(runTrace);
const toolPostconditionViolations = extractPostconditionViolations(runTrace);
try {
return await this.pipeline.verify({
runId,
userMessage: input.userMessage,
answer,
...(domainToolsCalled ? { domainToolsCalled } : {}),
...(toolPostconditionViolations.length > 0
? { toolPostconditionViolations }
: {}),
});
} catch (err) {
this.log(`[verifier/service] pipeline FAIL: ${errMsg(err)}`);
Expand Down Expand Up @@ -359,3 +363,49 @@ function extractToolsCalled(
}
return [...names];
}

/**
* #130 — collect every postcondition violation the bridgeTool stamped onto
* the runTrace. The verifier turns each entry into a synthetic
* `tool_postcondition` ClaimVerdict (status='contradicted'), which flips the
* verdict to `blocked` and drives the existing correctionPrompt retry loop.
*/
function extractPostconditionViolations(
trace: RunTracePayload | undefined,
): {
toolName: string;
callId: string;
agentContext: string;
issues: readonly string[];
}[] {
if (!trace) return [];
const out: {
toolName: string;
callId: string;
agentContext: string;
issues: readonly string[];
}[] = [];
for (const invocation of trace.agentInvocations) {
for (const call of invocation.toolCalls) {
if (call.postcondition) {
out.push({
toolName: call.toolName,
callId: call.callId,
agentContext: call.agentContext,
issues: call.postcondition.issues,
});
}
}
}
for (const call of trace.orchestratorToolCalls) {
if (call.postcondition) {
out.push({
toolName: call.toolName,
callId: call.callId,
agentContext: call.agentContext,
issues: call.postcondition.issues,
});
}
}
return out;
}
20 changes: 19 additions & 1 deletion middleware/packages/harness-verifier/src/claimTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ export type ClaimType =
| 'date' // concrete calendar date or period boundary
| 'name' // person / customer / vendor name with contextual assertion
| 'aggregate' // sum / count / avg over a set (especially HR leave)
| 'qualitative';// non-numeric statement about an entity ("X ist Kunde seit …")
| 'qualitative' // non-numeric statement about an entity ("X ist Kunde seit …")
| 'tool_postcondition'; // #130 — synthetic claim: a tool returned a value
// that didn't match its declared output Zod schema.
// Never produced by the LLM-side claim extractor;
// verifierPipeline manufactures one per violation
// it scans out of the runTrace before extraction.

/** Which subsystem is authoritative for this claim. */
export type ClaimSource = 'odoo' | 'graph' | 'confluence' | 'unknown';
Expand Down Expand Up @@ -111,6 +116,19 @@ export interface VerifierInput {
* pipeline then falls back to deterministic re-query (the existing path).
*/
domainToolsCalled?: readonly string[];
/**
* #130 — postcondition violations the bridge detected on tool returns
* (output Zod schema mismatch). Extracted from the runTrace before the
* pipeline runs; the pipeline manufactures a synthetic `tool_postcondition`
* ClaimVerdict with status='contradicted' for each entry. Drives the
* existing correctionPrompt retry loop.
*/
toolPostconditionViolations?: readonly {
toolName: string;
callId: string;
agentContext: string;
issues: readonly string[];
}[];
}

/** Badge used by the Teams card to communicate verifier status. */
Expand Down
35 changes: 33 additions & 2 deletions middleware/packages/harness-verifier/src/correctionPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,29 @@ export function buildCorrectionPrompt(
): string | undefined {
if (verdict.status !== 'blocked') return undefined;

const replayItems = verdict.contradictions.filter(isReplay);
const dataItems = verdict.contradictions.filter((v) => !isReplay(v));
const postconditionItems = verdict.contradictions.filter(isPostcondition);
const replayItems = verdict.contradictions.filter(
(v) => !isPostcondition(v) && isReplay(v),
);
const dataItems = verdict.contradictions.filter(
(v) => !isPostcondition(v) && !isReplay(v),
);

const sections: string[] = ['# Verifier hat Widersprüche erkannt', ''];

if (postconditionItems.length > 0) {
sections.push(
'## Tool-Output nicht spec-konform',
'',
'Ein Tool-Call hat ein Ergebnis zurückgeliefert, das nicht seinem deklarierten Output-Schema entspricht. Die Antwort darf sich auf diesen Wert NICHT verlassen.',
'',
'**Jetzt bitte:** rufe das gleiche Tool mit korrigierten Argumenten erneut auf (z.B. fehlende Felder ergänzen, Filter präzisieren) ODER nutze ein anderes Tool, das die benötigten Daten liefern kann. Wenn das Tool strukturell broken ist und kein Re-Call hilft, sag dem User ehrlich: "Tool X liefert kein verwertbares Ergebnis für Y".',
'',
...postconditionItems.map(formatPostcondition),
'',
);
}

if (replayItems.length > 0) {
sections.push(
'## Replay aus Kontext-Block erkannt',
Expand Down Expand Up @@ -48,11 +66,24 @@ export function buildCorrectionPrompt(
return sections.join('\n');
}

function isPostcondition(v: ClaimVerdict): boolean {
if (v.status !== 'contradicted') return false;
return v.claim.type === 'tool_postcondition';
}

function isReplay(v: ClaimVerdict): boolean {
if (v.status !== 'contradicted') return false;
return v.source === 'unknown' || v.claim.id.startsWith('c_replay');
}

function formatPostcondition(v: ClaimVerdict): string {
if (v.status !== 'contradicted') return '';
// claim.id format: `c_postcond_<callId>` — strip the prefix for display.
const callId = v.claim.id.replace(/^c_postcond_/, '');
const detail = v.detail ? ` — Issues: ${v.detail}` : '';
return `- ${v.claim.text} (callId=${callId})${detail}`;
}

function formatContradiction(v: ClaimVerdict): string {
if (v.status !== 'contradicted') return '';
const truthStr = formatTruth(v.truth);
Expand Down
51 changes: 47 additions & 4 deletions middleware/packages/harness-verifier/src/verifierPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,20 @@ export class VerifierPipeline {
// contradiction.
const replayVerdicts = detectFailureReplay(input);

// #130 — postcondition violations the bridgeTool detected on tool
// returns (output Zod schema mismatch). Same shape as replayVerdicts:
// synthetic contradicted verdicts that don't need answer extraction.
// The presence of any of these flips the aggregate to `blocked` and
// drives the existing correctionPrompt retry loop.
const postconditionVerdicts = buildPostconditionVerdicts(input);

const trigger = shouldTriggerVerifier(input.answer);
if (!trigger.shouldVerify) {
// Only the replay verdicts matter here.
return aggregate(replayVerdicts, started);
// Only the synthetic (no-extraction-needed) verdicts matter here.
return aggregate(
[...replayVerdicts, ...postconditionVerdicts],
started,
);
}

let claims: Claim[];
Expand All @@ -77,14 +87,20 @@ export class VerifierPipeline {
});
} catch (err) {
this.log(`[verifier/pipeline] extractor FAIL: ${errMsg(err)}`);
return aggregate(replayVerdicts, started);
return aggregate(
[...replayVerdicts, ...postconditionVerdicts],
started,
);
}

if (claims.length === 0) {
this.log(
`[verifier/pipeline] no claims extracted (trigger=${trigger.reasons.join(',')})`,
);
return aggregate(replayVerdicts, started);
return aggregate(
[...replayVerdicts, ...postconditionVerdicts],
started,
);
}

const { hard, soft } = classify(claims);
Expand Down Expand Up @@ -116,6 +132,7 @@ export class VerifierPipeline {

const all: ClaimVerdict[] = [
...replayVerdicts,
...postconditionVerdicts,
...traceVerdicts,
...hardVerdicts,
...softVerdicts,
Expand All @@ -124,6 +141,32 @@ export class VerifierPipeline {
}
}

/**
* #130 — turn each postcondition violation reported on the runTrace into a
* synthetic contradicted ClaimVerdict. The verifier never asks the extractor
* about these (they don't live in the answer text) and the deterministic
* checker never sees them either; they go straight into the aggregate.
*/
function buildPostconditionVerdicts(input: VerifierInput): ClaimVerdict[] {
const violations = input.toolPostconditionViolations;
if (!violations || violations.length === 0) return [];
return violations.map(
(v): ClaimVerdict => ({
status: 'contradicted',
claim: {
id: `c_postcond_${v.callId}`,
text: `Tool '${v.toolName}' returned a value that did not conform to its declared output schema.`,
type: 'tool_postcondition',
expectedSource: 'unknown',
relatedEntities: [],
},
truth: { issues: v.issues },
source: 'unknown',
detail: v.issues.join('; '),
}),
);
}

// --- helpers --------------------------------------------------------------

/**
Expand Down
Loading
Loading