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
2 changes: 1 addition & 1 deletion agentic/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"@agentic-kit/harness": "workspace:*",
"@agentic-kit/pi": "workspace:*",
"@constructive-io/sdk": "workspace:^",
"@earendil-works/pi-coding-agent": "0.79.6",
"@earendil-works/pi-coding-agent": "1.4.0",
"appstash": "^0.8.0",
"inquirerer": "^4.9.4"
},
Expand Down
2 changes: 1 addition & 1 deletion agentic/pi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"@earendil-works/pi-coding-agent": ">=0.79.0"
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "0.79.6"
"@earendil-works/pi-coding-agent": "1.4.0"
},
"keywords": [
"agentic-kit",
Expand Down
16 changes: 16 additions & 0 deletions agentic/run-log/__tests__/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ export const header = (over: Partial<PiSessionHeader> = {}): PiSessionEntry => (
const entry = (type: string, rest: Record<string, unknown>, seq: number): PiSessionEntry =>
({ type, id: nextId(), parentId: null, timestamp: at(seq), ...rest }) as PiSessionEntry;

export const systemMessage = (seq = 1, over: Record<string, unknown> = {}): PiSessionEntry =>
entry(
'message',
{
message: {
role: 'system',
content: '',
sections: { preamble: 'You are an expert coding assistant.' },
toolsAdded: [{ name: 'read', description: 'Read a file', parameters: {} }],
timestamp: seq,
...over
}
},
seq
);

export const userMessage = (text: string, seq = 1): PiSessionEntry =>
entry('message', { message: { role: 'user', content: text, timestamp: seq } }, seq);

Expand Down
12 changes: 12 additions & 0 deletions agentic/run-log/__tests__/projectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
futureEntry,
header,
resetIds,
systemMessage,
toolResult,
usage,
userMessage
Expand Down Expand Up @@ -117,6 +118,11 @@ describe('projectParts', () => {
expect(parts[5]).toMatchObject({ reason: 'branch', summary: 'branched' });
});

it('renders no part for a system message', () => {
const { parts } = projectParts(recordsOf(systemMessage(), userMessage('hi'), systemMessage(3)));
expect(parts).toEqual([expect.objectContaining({ kind: 'text', role: 'user', text: 'hi' })]);
});

it('surfaces an entry type it does not understand instead of dropping it', () => {
const { parts } = projectParts(recordsOf(futureEntry()));
expect(parts).toEqual([
Expand Down Expand Up @@ -240,6 +246,12 @@ describe('projectSession', () => {
expect(parseSessionJsonl(jsonl)).toEqual(entries);
});

it('keeps system messages, so the resumed parent chain stays whole', () => {
const system = systemMessage();
const { entries } = projectSession(recordsOf(header(), system, userMessage('hi')));
expect(entries[1]).toEqual(system);
});

it('synthesises a header when the log has none', () => {
const { entries } = projectSession(recordsOf(userMessage('hi')), { sessionId: 's-9', cwd: '/w' });
expect(entries[0]).toMatchObject({ type: 'session', id: 's-9', cwd: '/w', version: 3 });
Expand Down
16 changes: 16 additions & 0 deletions agentic/run-log/__tests__/spans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,22 @@ describe('projectSpans', () => {
expect(spans[0]).toMatchObject({ status: 'error', errorMessage: 'overloaded' });
});

it.each(['pending', 'deferred'] as const)('keeps a %s model turn open, since its response has not arrived', (stopReason) => {
const { spans } = projectSpans(
recordsOf(
userMessage('go', 1),
assistantToolCall({ id: 'call-1', name: 'bash' }, 2, { stopReason, responseId: 'resp-7' })
)
);

const model = spans.find((span): span is ModelSpan => span.kind === 'model');
expect(model).toMatchObject({ status: 'open', stopReason, responseId: 'resp-7', startSeq: 2 });
expect(model!.endSeq).toBeUndefined();
expect(model!.endedAt).toBeUndefined();
expect(model!.durationMs).toBeUndefined();
expect(byId(spans, 'tool:call-1')).toMatchObject({ parentId: model!.id });
});

it('ignores entry types it has never seen', () => {
const { spans } = projectSpans(recordsOf(header(), userMessage('go', 1), futureEntry(2)));

Expand Down
12 changes: 11 additions & 1 deletion agentic/run-log/__tests__/transcripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
transcriptReaders,
wrapEntry
} from '../src';
import { assistantToolCall, header, resetIds, toolResult, usage } from './fixtures';
import { assistantText, assistantToolCall, header, resetIds, systemMessage, toolResult, usage } from './fixtures';

beforeEach(resetIds);

Expand Down Expand Up @@ -244,6 +244,16 @@ describe('the pi reader', () => {
expect(events[1]).toMatchObject({ kind: 'tool-call', toolCallId: 'c1', name: 'run_codegen' });
});

it('reads a system message as no event, so the prompt never reaches a projection', () => {
expect(piTranscriptReader.toEvents(systemMessage())).toEqual([]);
expect(piTranscriptReader.toEvents(systemMessage(2, { sections: { skills: null }, toolsRemoved: [{ name: 'read' }] }))).toEqual([]);
});

it.each(['pending', 'deferred'] as const)('carries the %s stop reason into the model response', (stopReason) => {
const [event] = piTranscriptReader.toEvents(assistantText('later', 2, { stopReason }));
expect(event).toMatchObject({ kind: 'model-response', stopReason });
});

it('carries tool result usage into the neutral event', () => {
const [event] = piTranscriptReader.toEvents(
toolResult({ toolCallId: 'c1', toolName: 'run_codegen', text: 'ok', usage: usage() })
Expand Down
2 changes: 2 additions & 0 deletions agentic/run-log/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,11 @@ export {
type PiSessionEntry,
type PiSessionHeader,
type PiSummaryMessage,
type PiSystemMessage,
type PiTextContent,
type PiThinkingContent,
type PiToolCallContent,
type PiToolDeclaration,
type PiToolResultMessage,
type PiUsage,
type PiUsageCost,
Expand Down
13 changes: 8 additions & 5 deletions agentic/run-log/src/projectors/spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ const close = (span: Span, seq: number, at: string): void => {
if (duration !== undefined) span.durationMs = duration;
};

/** A pending or deferred response has not arrived yet, so its turn has not ended. */
const modelStatus = (stopReason: string | undefined): SpanStatus => {
if (stopReason === 'pending' || stopReason === 'deferred') return 'open';
return stopReason === 'error' ? 'error' : 'ok';
};
Comment on lines +109 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

aborted and unknown stopReasons map to ok

The rewritten modelStatus helper (agentic/run-log/src/projectors/spans.ts:109-113) returns 'ok' for every stopReason except pending/deferred/error, so a user-aborted turn (stopReason: 'aborted', a member of the union at agentic/run-log/src/transcripts/pi-entry.ts:92) is closed with status: 'ok' and a duration. Unrecognized stopReasons from a newer writer are likewise flattened to success, unlike the projector's otherwise-explicit unknown handling. Trace consumers cannot distinguish a completed turn from a cancelled one.

📋 Prompt for AI Agents

In agentic/run-log/src/projectors/spans.ts at modelStatus (lines 109-113), map stopReason 'aborted' to a non-success status — either return 'error' or extend SpanStatus (line 31) with 'aborted' — and add a final branch for unrecognized stopReason values that keeps the span open or returns an 'unknown' status, so aborted and future stopReason semantics are not reported as success. Update spans projection tests to cover an 'aborted' entry and a fabricated stopReason value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this here. aborted already mapped to ok before this PR; only the pending/deferred branch is new. The raw stopReason stays on the span, so consumers can tell an aborted turn from a completed one. Adding an aborted status widens SpanStatus, which downstream consumers switch on, so it should be its own change.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The concern stands. The current diff still shows the same return stopReason === 'error' ? 'error' : 'ok' fallthrough, meaning 'aborted' maps to 'ok'. The argument that the raw stopReason field is still on the span is reasonable for consumers who inspect that field directly, but it does not address the semantic mismatch of status: 'ok' on an aborted turn — consumers switching on status alone will misread it as success. The fix (adding an explicit 'aborted' branch or an unknown-fallback) is not visible in this hunk. If it landed elsewhere in the file or in a related file, I will need more context to verify.


const detailsOf = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};

Expand Down Expand Up @@ -150,19 +156,16 @@ export function projectSpans(
// no previous entry the interval is unknown and the span is a point.
startedAt: previous ?? at,
startSeq: seq,
endSeq: seq,
endedAt: at,
timing: 'bounded',
status: event.stopReason === 'error' ? 'error' : 'ok',
status: modelStatus(event.stopReason),
...(event.model ? { model: event.model } : {}),
...(event.provider ? { provider: event.provider } : {}),
...(event.responseId ? { responseId: event.responseId } : {}),
...(event.stopReason ? { stopReason: event.stopReason } : {}),
...(event.usage ? { usage: event.usage } : {}),
...(event.errorMessage ? { errorMessage: event.errorMessage } : {})
};
const duration = ms(span.startedAt, at);
if (duration !== undefined) span.durationMs = duration;
if (span.status !== 'open') close(span, seq, at);
modelSpan = span;
spans.push(span);
continue;
Expand Down
23 changes: 22 additions & 1 deletion agentic/run-log/src/transcripts/pi-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,26 @@ export interface PiToolCallContent {

export type PiContent = PiTextContent | PiThinkingContent | PiImageContent | PiToolCallContent;

export interface PiToolDeclaration {
name: string;
[key: string]: unknown;
}

/**
* The prompt and tool loadout. The first request of a session persists one with
* every section and tool; later ones patch `sections` by name (`null` removes
* one) and list the tools added or removed.
*/
export interface PiSystemMessage {
role: 'system';
content: string | PiTextContent[];
sections?: Record<string, string | null>;
toolsAdded?: PiToolDeclaration[];
toolsRemoved?: PiToolDeclaration[];
timestamp?: number;
[key: string]: unknown;
}

export interface PiUserMessage {
role: 'user';
content: string | PiContent[];
Expand All @@ -69,7 +89,7 @@ export interface PiAssistantMessage {
provider?: string;
model?: string;
usage?: PiUsage;
stopReason?: 'stop' | 'length' | 'toolUse' | 'error' | 'aborted';
stopReason?: 'pending' | 'stop' | 'length' | 'toolUse' | 'error' | 'aborted' | 'deferred';
errorMessage?: string;
timestamp?: number;
[key: string]: unknown;
Expand Down Expand Up @@ -116,6 +136,7 @@ export interface PiSummaryMessage {
}

export type PiMessage =
| PiSystemMessage
| PiUserMessage
| PiAssistantMessage
| PiToolResultMessage
Expand Down
3 changes: 3 additions & 0 deletions agentic/run-log/src/transcripts/pi-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export function piEntryToEvents(entry: TranscriptEntry): TranscriptEvent[] {

const message = pi.message;

// The full prompt and tool schemas: context for the model, not part of the transcript.
if (message.role === 'system') return [];

if (message.role === 'user') {
return [{ kind: 'text', role: 'user', text: contentText(message.content), ...base }];
}
Expand Down
Loading
Loading