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
1 change: 1 addition & 0 deletions .github/skills/agentic-workflows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Load these files from `github/gh-aw` (they are not available locally).
- `.github/aw/charts-trending.md`
- `.github/aw/charts.md`
- `.github/aw/cli-commands.md`
- `.github/aw/configure-agentic-engine.md`
- `.github/aw/context.md`
- `.github/aw/create-agentic-workflow-trigger-details.md`
- `.github/aw/create-agentic-workflow.md`
Expand Down
251 changes: 247 additions & 4 deletions actions/setup/js/parse_pi_log.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,15 @@ function parsePiLog(logContent) {
};
}

const logEntries = transformPiEntries(rawEntries);
// Pi CLI's `--mode json` output schema changed over time. Current builds emit a
// v3 streaming schema (session, turn_start, turn_end, tool_execution_start/end, agent_end)
// while older builds emitted a flat init/assistant/tool_use/tool_result/result schema.
// Detect which schema this log uses and transform accordingly so the step summary
// renders the conversation and token stats for both.
const useV3Schema = isPiV3Schema(rawEntries);
const logEntries = useV3Schema ? transformPiV3Entries(rawEntries) : transformPiEntries(rawEntries);

const resultEntry = rawEntries.find(e => e.type === "result");
const stats = useV3Schema ? computePiV3Stats(rawEntries) : legacyPiStats(rawEntries);

const canonicalLogEntries = convertLegacyLogEntriesToCopilotEvents(logEntries, { sourceEngine: "pi" });
const conversationResult = generateConversationMarkdown(canonicalLogEntries, {
Expand All @@ -73,8 +79,7 @@ function parsePiLog(logContent) {

let markdown = conversationResult.markdown;

if (resultEntry && resultEntry.stats) {
const stats = resultEntry.stats;
if (stats) {
const syntheticEntry = {
usage: {
input_tokens: stats.input_tokens || 0,
Expand Down Expand Up @@ -181,6 +186,241 @@ function transformPiEntries(rawEntries) {
return entries;
}

/**
* Detects whether the raw Pi entries use the v3 streaming schema.
*
* The v3 schema emits envelope events (session, turn_end, tool_execution_start/end, agent_end)
* that the legacy flat schema (init/assistant/tool_use/tool_result/result) never uses.
* A single marker event is enough to distinguish the two.
*
* @param {Array<any>} rawEntries - Raw parsed JSONL entries
* @returns {boolean} True when the log uses the v3 streaming schema
*/
function isPiV3Schema(rawEntries) {
for (const e of rawEntries) {
if (!e || typeof e.type !== "string") {
continue;
}
if (
e.type === "turn_end" ||
e.type === "turn_start" ||
e.type === "agent_end" ||
e.type === "agent_start" ||
e.type === "tool_execution_start" ||
e.type === "tool_execution_end" ||
e.type === "message_update" ||
(e.type === "session" && typeof e.version === "number")
) {
return true;
}
}
return false;

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.

isPiV3Schema returns true on any single v3 event, forcing the entire log through the v3 path — a single stray v3 event in a legacy log silently drops the whole conversation.

💡 Detail and suggested fix

Any single matching event (including a bare {type:"agent_start"} injected by a wrapper script or log aggregator) causes isPiV3Schema to return true, after which transformPiV3Entries processes the log as v3 and ignores all legacy assistant / tool_use / result events. The resulting conversation will be empty or severely truncated with no error surfaced.

Two concrete failure scenarios:

  1. A legacy log that happens to include a session-management envelope at the top (e.g. from a launcher) returns true and renders nothing.
  2. A partial v3 log with both schema types (mid-migration Pi build) silently loses all legacy-schema content.

Consider requiring at least one turn_end (the load-bearing event for v3 content) before committing to the v3 path:

This is a stronger signal: v3 logs always finalize turns with turn_end, so requiring both a v3 marker and at least one turn_end eliminates false positives from stray envelope events.

}

/**
* Transforms raw Pi v3 streaming entries into the canonical logEntries format.
*
* The v3 schema streams a message in fragments (message_start/message_update/message_end)
* and finalizes each turn with a `turn_end` event carrying the complete assistant message
* (`content[]` of `{type:"text"}` and `{type:"toolCall", id, name, arguments}`). Tool
* results arrive as `tool_execution_end` events keyed by `toolCallId`. We render from the
* finalized `turn_end` messages (avoiding fragment duplication) and pair each tool call
* with its result by id.
*
* Ordering matters: `generateConversationMarkdown` resolves a tool result's name from the
* preceding tool_use, so each turn emits its assistant tool_use entries first, then the
* matching tool_result entries — even though `tool_execution_end` precedes `turn_end` in
* the raw stream.
*
* @param {Array<any>} rawEntries - Raw parsed JSONL entries
* @returns {Array<any>} Canonical log entries for generateConversationMarkdown
*/
function transformPiV3Entries(rawEntries) {
/** @type {Array<any>} */
const entries = [];

// Index tool execution results by their tool call id for pairing.
/** @type {Map<string, any>} */
const resultsById = new Map();
for (const raw of rawEntries) {
if (raw.type === "tool_execution_end" && raw.toolCallId) {
resultsById.set(raw.toolCallId, raw);
}
}

// Initialization entry from the session event, with the model taken from the first

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.

computePiV3Stats hardcodes duration_ms: 0, silently emitting incorrect latency data to OTEL consumers.

💡 Detail and suggested fix

The v3 stream includes timing data that makes duration_ms derivable. The session event has a timestamp field, and agent_end typically carries a timestamp too. Hardcoding 0 means every v3 run reports zero duration in OTEL spans and the step summary information section.

If the upstream agent_end event carries a timestamp, compute the wall-clock duration:

const sessionEvent = rawEntries.find(e => e.type === "session");
const agentEndEvent = rawEntries.find(e => e.type === "agent_end");
let duration_ms = 0;
if (sessionEvent && sessionEvent.timestamp && agentEndEvent && agentEndEvent.timestamp) {
  duration_ms = new Date(agentEndEvent.timestamp) - new Date(sessionEvent.timestamp);
  if (!isFinite(duration_ms) || duration_ms < 0) duration_ms = 0;
}

If agent_end does not carry a timestamp in the real schema, document that duration_ms is intentionally unavailable rather than silently emitting 0.

// finalized turn (v3 session events do not carry the model directly).
const session = rawEntries.find(e => e.type === "session");
const modeledTurn = rawEntries.find(e => e.type === "turn_end" && e.message && e.message.model);
const model = modeledTurn ? modeledTurn.message.model : undefined;
if (session || model) {
entries.push({
type: "system",
subtype: "init",
model: model,
session_id: session ? session.id : undefined,
});
}

/** @type {Set<string>} */
const emittedResults = new Set();

for (const raw of rawEntries) {
if (raw.type !== "turn_end" || !raw.message || !Array.isArray(raw.message.content)) {
continue;
}

/** @type {Array<string>} */
const pendingToolIds = [];
for (const part of raw.message.content) {
if (!part || typeof part !== "object") {
continue;
}
if (part.type === "text") {
const text = typeof part.text === "string" ? part.text : "";
if (!text.trim()) {
continue;
}
entries.push({
type: "assistant",
message: { content: [{ type: "text", text }] },
});
} else if (part.type === "toolCall") {
entries.push({
type: "assistant",
message: {
content: [{ type: "tool_use", id: part.id, name: part.name, input: part.arguments || {} }],
},
});
if (part.id) {
pendingToolIds.push(part.id);
}
}
}

// Emit tool results for this turn's calls, after their tool_use entries.
for (const id of pendingToolIds) {
if (emittedResults.has(id)) {
continue;
}
const res = resultsById.get(id);
if (!res) {
continue;
}
emittedResults.add(id);
entries.push({
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: id,
content: extractPiV3ResultText(res.result),
is_error: res.isError || isPiV3ResultError(res.result),
},
],
},
});
}
}

return entries;
}

/**
* Extracts the textual output from a Pi v3 tool execution result.
* @param {any} result - The `result` field of a tool_execution_end event
* @returns {string} The concatenated text output
*/
function extractPiV3ResultText(result) {
if (!result) {
return "";
}
if (typeof result === "string") {
return result;
}
if (Array.isArray(result.content)) {
return result.content.map(c => (c && typeof c.text === "string" ? c.text : typeof c === "string" ? c : "")).join("");
}
if (typeof result.output === "string") {
return result.output;
}
return JSON.stringify(result);
}

/**
* Determines whether a Pi v3 tool execution result represents an error.
* @param {any} result - The `result` field of a tool_execution_end event
* @returns {boolean} True when the result indicates an error
*/
function isPiV3ResultError(result) {
return !!(result && (result.isError === true || result.is_error === true || result.status === "error"));
}

/**
* Computes aggregate stats from Pi v3 entries for the information section.
*
* Each `turn_end` carries a `usage` object; both output and input tokens are summed across
* turns, matching the accumulation performed by the Pi driver. Turn count is the number of
* finalized turns.
*
* @param {Array<any>} rawEntries - Raw parsed JSONL entries
* @returns {{input_tokens:number, output_tokens:number, turns:number, duration_ms:number}|null} Stats or null when unavailable
*/
function computePiV3Stats(rawEntries) {
let outputTokens = 0;
let inputTokens = 0;
let turns = 0;
let sawUsage = false;

for (const raw of rawEntries) {
if (raw.type !== "turn_end") {
continue;
}
turns++;
const usage = raw.message && raw.message.usage;
if (usage && typeof usage === "object") {
sawUsage = true;
if (typeof usage.output === "number") {
outputTokens += usage.output;
}
if (typeof usage.input === "number") {
inputTokens += usage.input;
}
}
}

if (turns === 0 && !sawUsage) {

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.

[/diagnosing-bugs] When turns > 0 but no usage is present (e.g. a future Pi build stops emitting per-turn usage), computePiV3Stats returns {input_tokens:0, output_tokens:0, turns:N} rather than null. The step summary would show a stats section with all-zero tokens, which is misleading rather than absent.

💡 Suggested fix

Change the guard to also return null when no usage data was observed:

if (!sawUsage) {
  return null; // no token data available — omit the stats block entirely
}

And add a test case:

it('returns null when turns exist but no usage fields', () => {
  expect(computePiV3Stats([{ type: 'turn_end', message: { content: [] } }])).toBeNull();
});

@copilot please address this.

return null;

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.

computePiV3Stats returns misleading zero-token stats instead of null when turn_end events exist but carry no usage data.

💡 Detail and suggested fix

The null-guard at line ~395 is:

if (turns === 0 && !sawUsage) {
  return null;
}

If turn_end entries exist but carry no usage field (older v3 build, streaming error, truncated log), turns > 0 while sawUsage stays false. The function returns { input_tokens: 0, output_tokens: 0, turns: N, duration_ms: 0 } instead of null, so the caller emits a synthetic stats block with all-zero token counts into the OTEL enrichment path — downstream gh-aw.turns / token-usage metrics show turns with zero tokens rather than absent data.

Fix the guard:

if (!sawUsage) {
  return turns === 0 ? null : { input_tokens: 0, output_tokens: 0, turns, duration_ms: 0 };
}

Or simply return null when !sawUsage if downstream consumers prefer fully absent stats over partial data.

}

return {
input_tokens: inputTokens,
output_tokens: outputTokens,
turns: turns,
duration_ms: 0,
};
}

/**
* Extracts stats from a legacy Pi `result` event, preserving the original flat-schema behavior.
* @param {Array<any>} rawEntries - Raw parsed JSONL entries
* @returns {{input_tokens:number, output_tokens:number, turns:number, duration_ms:number}|null} Stats or null when absent
*/
function legacyPiStats(rawEntries) {
const resultEntry = rawEntries.find(e => e.type === "result");
if (!resultEntry || !resultEntry.stats) {
return null;
}
const stats = resultEntry.stats;
return {
input_tokens: stats.input_tokens || 0,
output_tokens: stats.output_tokens || 0,
turns: stats.turns || 0,
duration_ms: stats.duration_ms || 0,
};
}

/**
* Checks whether a canonical log entry is an assistant text entry eligible for merging
* with a subsequent streaming delta chunk.
Expand All @@ -197,5 +437,8 @@ if (typeof module !== "undefined" && module.exports) {
main,
parsePiLog,
transformPiEntries,
isPiV3Schema,
transformPiV3Entries,
computePiV3Stats,
};

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.

[/tdd] legacyPiStats is not exported, so it can only be exercised indirectly via parsePiLog — but no legacy-schema fixture exists in the new test suite. A rename of result.stats field names would silently produce zero stats with no failing test.

💡 Suggested fix

Add legacyPiStats to module.exports and add unit tests:

it('extracts stats from a legacy result event', () => {
  const stats = legacyPiStats([{ type: 'result', stats: { input_tokens: 100, output_tokens: 50, turns: 3, duration_ms: 1500 } }]);
  expect(stats).toEqual({ input_tokens: 100, output_tokens: 50, turns: 3, duration_ms: 1500 });
});
it('returns null when no legacy result event', () => {
  expect(legacyPiStats([{ type: 'init' }])).toBeNull();
});

@copilot please address this.

}
Loading
Loading