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
17 changes: 17 additions & 0 deletions docs/guides/programmatic/turn-results.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const result = await engine.turns.submit({ sessionId, prompt, host })

console.log(result.outcome)
console.log(result.summary)
console.log(result.failure)
console.log(result.traceFile)
console.log(result.artifacts.map((artifact) => artifact.id))
console.log(result.toolResults.map((entry) => entry.call.tool))
Expand All @@ -17,6 +18,10 @@ Fields:

- `outcome`: why the turn stopped.
- `summary`: persisted assistant summary for the turn.
- `failure`: optional safe structured category for a failed model run. It has
the shape `{ source: 'model', code }` and never contains credentials or raw
provider messages. Use this field for stable product behavior instead of
parsing `summary`.
- `session`: updated persisted session.
- `traceFile`: path to the persisted raw trace file.
- `artifacts`: current artifacts associated with the session.
Expand All @@ -25,3 +30,15 @@ Fields:

Use these fields for product summaries and run artifacts. Reach for raw trace
files only when your host needs lower-level evidence or custom analysis.

For example, a hosted product can translate a rejected user-supplied model
credential into its own API error without coupling to an OpenAI error string:

```ts
if (result.failure?.code === 'authentication') {
throw new ProductModelCredentialError()
}
```

Model failure codes currently distinguish `authentication`, `permission`,
`rate_limit`, `request`, `transport`, `empty_response`, and `unknown`.
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export type HostedAgentRunAccepted = {
sessionId: string;
};

export type HostedAgentResult = Pick<ConversationTurnResultSummary, 'outcome' | 'summary'>;
export type HostedAgentResult = Pick<ConversationTurnResultSummary, 'outcome' | 'summary' | 'failure'>;

export type HostedAgentRunStreamItem = ConversationRunStreamItem<HostedAgentResult>;

Expand Down Expand Up @@ -109,6 +109,7 @@ export class HostedAgentService {
projectResult: (result): HostedAgentResult => ({
outcome: result.outcome,
summary: result.summary,
...(result.failure ? { failure: result.failure } : {}),
}),
projectError: () => ({
code: 'run_failed',
Expand Down
12 changes: 12 additions & 0 deletions examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ const HostedAgentActivitySchema = z.object({
const HostedAgentResultSchema = z.object({
outcome: z.string().min(1),
summary: z.string(),
failure: z.object({
source: z.literal('model'),
code: z.enum([
'authentication',
'permission',
'rate_limit',
'request',
'transport',
'empty_response',
'unknown',
]),
}).optional(),
});

export const HostedAgentRunProtocol = new ConversationRunProtocolCodec({
Expand Down
19 changes: 10 additions & 9 deletions examples/sdk/05-hosted-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,10 @@ configuration, injected repositories, approval decisions, and process
lifecycle.

The example's `projectResult` callback reduces Heddle's internal turn result to
`outcome` and `summary`. In a product, this callback is also the place to await
authorized state persistence or reconciliation before clients observe success.
Projection failure becomes the run's error terminal; it cannot race behind a
premature successful result.
`outcome`, `summary`, and the optional safe `failure` category. In a product,
this callback is also the place to await authorized state persistence or
reconciliation before clients observe success. Projection failure becomes the
run's error terminal; it cannot race behind a premature successful result.

The adjacent `projectError` callback keeps raw model, tool, provider, and
persistence failures on the host side. Remote clients receive only the
Expand Down Expand Up @@ -188,11 +188,12 @@ registers host-owned Express routes and composes
- closing an SSE connection aborts only that subscription, not the run;
- cancel is a separate, authenticated operation.

The hosted service deliberately projects terminal results to public `outcome`
and `summary` fields before replay. The API schema validates that boundary
again. Trace paths, artifacts, tool results, and internal session state are not
serialized to the browser. Extend the public projection and Zod schema with
only the product data the client is authorized to receive.
The hosted service deliberately projects terminal results to public `outcome`,
`summary`, and optional safe `failure` fields before replay. The API schema
validates that boundary again. Trace paths, artifacts, tool results, and
internal session state are not serialized to the browser. Extend the public
projection and Zod schema with only the product data the client is authorized
to receive.

The session read/reset endpoints are example host operations used by the React
stage. They project persisted visible messages plus the process-local active
Expand Down
38 changes: 35 additions & 3 deletions src/__tests__/integration/control-plane/session-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,32 @@ describe('conversation turn lifecycle', () => {
]);
});

it('returns the safe model failure category to programmatic hosts', async () => {
const storage = createConversationTurnStorage();
vi.spyOn(agentLoopModule.AgentLoopRuntimeService, 'run').mockResolvedValue(createLoopResult({
workspaceRoot: storage.workspaceRoot,
prompt: 'Use a rejected credential.',
summary: 'LLM error: Model authentication failed',
outcome: 'error',
failure: { source: 'model', code: 'authentication' },
}) as never);

const turnResult = await EngineConversationTurnService.run({
workspaceRoot: storage.workspaceRoot,
stateRoot: storage.stateRoot,
traceDir: join(storage.stateRoot, 'traces'),
sessionStoragePath: storage.sessionStoragePath,
sessionId: storage.sessionId,
prompt: 'Use a rejected credential.',
apiKey: 'rejected-key',
memoryMaintenanceMode: 'none',
artifactRoot: storage.artifactRoot,
artifactsEnabled: true,
});

expect(turnResult.failure).toEqual({ source: 'model', code: 'authentication' });
});

it('clears the session lease when the run loop fails', async () => {
const storage = createConversationTurnStorage();
vi.spyOn(agentLoopModule.AgentLoopRuntimeService, 'run').mockRejectedValue(new Error('loop failed'));
Expand Down Expand Up @@ -469,8 +495,11 @@ function createLoopResult(args: {
workspaceRoot: string;
prompt: string;
summary: string;
outcome?: RunResult['outcome'];
failure?: RunResult['failure'];
trace?: RunResult['trace'];
}) {
const outcome = args.outcome ?? 'done';
const trace: RunResult['trace'] = args.trace ?? [
{
type: 'assistant.turn',
Expand All @@ -481,8 +510,9 @@ function createLoopResult(args: {
},
{
type: 'run.finished',
outcome: 'done',
outcome,
summary: args.summary,
...(args.failure ? { failure: args.failure } : {}),
step: 1,
timestamp: '2026-05-03T00:00:02.000Z',
},
Expand All @@ -493,8 +523,9 @@ function createLoopResult(args: {
];

return {
outcome: 'done',
outcome,
summary: args.summary,
...(args.failure ? { failure: args.failure } : {}),
trace,
transcript,
model: 'gpt-5.4',
Expand All @@ -509,8 +540,9 @@ function createLoopResult(args: {
workspaceRoot: args.workspaceRoot,
startedAt: '2026-05-03T00:00:00.000Z',
finishedAt: '2026-05-03T00:00:02.000Z',
outcome: 'done',
outcome,
summary: args.summary,
...(args.failure ? { failure: args.failure } : {}),
transcript,
trace,
},
Expand Down
39 changes: 39 additions & 0 deletions src/__tests__/integration/core/agent-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,45 @@ describe('AgentLoopRuntimeService.run', () => {
});
});

it('propagates safe model failures through loop state and terminal activity', async () => {
const events: AgentLoopEvent[] = [];
const fakeLlm: LlmAdapter = {
info: {
provider: 'openai',
model: 'gpt-test',
capabilities: {
toolCalls: true,
systemMessages: true,
reasoningSummaries: false,
parallelToolCalls: true,
},
},
async chat(): Promise<LlmResponse> {
throw Object.assign(new Error('Unauthorized'), { status: 401 });
},
};

const result = await AgentLoopRuntimeService.run({
goal: 'Use a rejected credential.',
llm: fakeLlm,
tools: [],
includeDefaultTools: false,
logger: silentLogger,
workspaceRoot: resolve('/tmp/heddle-loop-failure-test'),
onEvent: (event) => events.push(event),
});

expect(result.failure).toEqual({ source: 'model', code: 'authentication' });
expect(result.state.failure).toEqual({ source: 'model', code: 'authentication' });
expect(events.at(-1)).toMatchObject({
type: 'loop.finished',
failure: { source: 'model', code: 'authentication' },
state: {
failure: { source: 'model', code: 'authentication' },
},
});
});

it('emits assistant stream events through the programmatic event stream', async () => {
const events: AgentHeartbeatEvent[] = [];
const fakeLlm: LlmAdapter = {
Expand Down
18 changes: 13 additions & 5 deletions src/__tests__/integration/core/run-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,11 @@ describe('AgentRunService.run', () => {

it('records an error outcome when the LLM chat throws a non-retryable error', async () => {
let calls = 0;
const providerSecret = 'sk-provider-error-sentinel';
const fakeLlm: LlmAdapter = {
async chat(): Promise<LlmResponse> {
calls += 1;
throw Object.assign(new Error('Unauthorized'), { status: 401 });
throw Object.assign(new Error(`Unauthorized: ${providerSecret}`), { status: 401 });
},
};

Expand All @@ -120,9 +121,15 @@ describe('AgentRunService.run', () => {
});

expect(result.outcome).toBe('error');
expect(result.summary).toBe('LLM error: Unauthorized');
expect(result.summary).toBe('LLM error: Model authentication failed');
expect(result.failure).toEqual({ source: 'model', code: 'authentication' });
expect(calls).toBe(1);
expect(result.trace.some((event) => event.type === 'run.finished')).toBe(true);
expect(result.trace.at(-1)).toMatchObject({
type: 'run.finished',
outcome: 'error',
failure: { source: 'model', code: 'authentication' },
});
expect(JSON.stringify(result)).not.toContain(providerSecret);
});

it('retries transient LLM transport errors before returning the assistant response', async () => {
Expand Down Expand Up @@ -158,14 +165,14 @@ describe('AgentRunService.run', () => {
reason: 'transport_error',
attempt: 1,
maxAttempts: 5,
message: 'fetch failed',
message: 'Model provider is temporarily unavailable',
}),
expect.objectContaining({
type: 'model.retry',
reason: 'transport_error',
attempt: 2,
maxAttempts: 5,
message: 'fetch failed',
message: 'Model provider is temporarily unavailable',
}),
]);
});
Expand All @@ -189,6 +196,7 @@ describe('AgentRunService.run', () => {

expect(result.outcome).toBe('error');
expect(result.summary).toBe('Model returned an empty response after 3 attempts');
expect(result.failure).toEqual({ source: 'model', code: 'empty_response' });
expect(calls).toBe(3);
expect(result.trace.filter((event) => event.type === 'model.retry')).toHaveLength(2);
});
Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/integration/memory/memory-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,9 @@ describe('memory maintenance integration', () => {
expect(result.events.map((event) => event.type)).toEqual(['memory.maintenance_started', 'memory.maintenance_failed']);
expect(result.events[1]).toMatchObject({
type: 'memory.maintenance_failed',
error: expect.stringContaining('maintainer unavailable'),
error: 'LLM error: Model request failed',
});
expect(JSON.stringify(result.events)).not.toContain('maintainer unavailable');
});

it('serializes maintenance runs per memory root', async () => {
Expand Down
36 changes: 36 additions & 0 deletions src/__tests__/unit/core/agent-model-turn-retry-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { AgentModelTurnRetryService } from '@/core/agent/model/index.js';

describe('AgentModelTurnRetryService', () => {
it.each([
[401, 'authentication', false],
[403, 'permission', false],
[400, 'request', false],
[429, 'rate_limit', true],
[503, 'transport', true],
[418, 'unknown', false],
] as const)('classifies HTTP status %s as %s', (status, code, retryable) => {
const decision = AgentModelTurnRetryService.resolve({
kind: 'error',
error: Object.assign(new Error('provider message'), { status }),
});

expect(decision).toMatchObject({
retryable,
failure: { source: 'model', code },
});
expect(decision.failure).not.toHaveProperty('message');
});

it('classifies network failures without exposing provider details', () => {
const decision = AgentModelTurnRetryService.resolve({
kind: 'error',
error: Object.assign(new Error('fetch failed with secret-value'), { code: 'ECONNRESET' }),
});

expect(decision.failure).toEqual({ source: 'model', code: 'transport' });
expect(decision.message).toBe('Model provider is temporarily unavailable');
expect(decision.message).not.toContain('secret-value');
expect(JSON.stringify(decision.failure)).not.toContain('secret-value');
});
});
1 change: 1 addition & 0 deletions src/core/agent/finish/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type {
AgentRunFinishedValue,
FinishAgentRunArgs,
FinishAgentRunLogging,
FinishAgentRunOptions,
FinishAssistantResponseArgs,
MaybeFinishInterruptedArgs,
} from './types.js';
Loading
Loading