From 3ecb0921baec8626f6144fffe5efbb0e2ab42e87 Mon Sep 17 00:00:00 2001 From: Dominik Pinsel Date: Wed, 12 Aug 2026 17:18:52 +0200 Subject: [PATCH] feat(runner): inline raw event payload into agent prompt The runner delivered only a minimal summary block (type, org, repo, issue/pr number, actor) to the agent, discarding the full webhook payload that the hub already delivered in task.payload. Agents had no way to see comment bodies, issue titles or other event details without guessing tool calls. Append the raw payload as an JSON block after the event summary, capped by EVENT_DATA_MAX_CHARS (default 16000 chars) using the existing head+tail truncateWithMarker. --- pi/pi-extensions/ainsel-runner/index.test.ts | 101 ++++++++++++++++++- pi/pi-extensions/ainsel-runner/index.ts | 30 +++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/pi/pi-extensions/ainsel-runner/index.test.ts b/pi/pi-extensions/ainsel-runner/index.test.ts index 9430e1b..f377b9c 100644 --- a/pi/pi-extensions/ainsel-runner/index.test.ts +++ b/pi/pi-extensions/ainsel-runner/index.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { extractEventContext, createTurnTracker, beginTurn, endTurn, recordAssistantEnd, captureMessage, markSettled, waitForSettle, toConversationPayloads, postTaskMessages, reportConversation, redactSecrets, resetRedactionCache, capToolResultContent, truncateWithMarker, resolveToolResultMaxChars, resolveInternalToken } from "./index.ts"; +import { extractEventContext, buildUserMessage, createTurnTracker, beginTurn, endTurn, recordAssistantEnd, captureMessage, markSettled, waitForSettle, toConversationPayloads, postTaskMessages, reportConversation, redactSecrets, resetRedactionCache, capToolResultContent, truncateWithMarker, resolveToolResultMaxChars, resolveEventDataMaxChars, resolveInternalToken } from "./index.ts"; import type { HubEvent, TurnTracker, ConversationPayload } from "./index.ts"; describe("extractEventContext", () => { @@ -194,6 +194,105 @@ describe("extractEventContext", () => { }); }); +describe("buildUserMessage", () => { + const webhookCtx = { + type: "issue_comment.created", + actor: "alice", + owner: "acme", + repo: "web", + number: 83, + kind: "issue", + id: "evt-1", + }; + + it("inlines the raw payload in an event-data block", () => { + const payload = { + action: "created", + comment: { body: "@review-agent wdyt?" }, + }; + const msg = buildUserMessage(webhookCtx, payload); + + assert.ok(msg.includes("type: issue_comment.created")); + assert.ok(msg.includes("issue: 83")); + assert.ok(msg.includes("")); + assert.ok(msg.includes("")); + assert.ok(msg.includes("@review-agent wdyt?")); + assert.ok(msg.includes('"action": "created"')); + }); + + it("omits the event-data block when no payload is given", () => { + const msg = buildUserMessage(webhookCtx); + assert.ok(!msg.includes("")); + assert.ok(msg.includes("Handle the issue_comment.created")); + }); + + it("omits the event-data block for null payloads", () => { + const msg = buildUserMessage(webhookCtx, null); + assert.ok(!msg.includes("")); + }); + + it("truncates oversized payloads with a marker", () => { + const prev = process.env.EVENT_DATA_MAX_CHARS; + process.env.EVENT_DATA_MAX_CHARS = "200"; + try { + const payload = { blob: "x".repeat(5000) }; + const msg = buildUserMessage(webhookCtx, payload); + const start = msg.indexOf("") + "\n".length; + const end = msg.lastIndexOf(""); + const body = msg.slice(start, end); + assert.ok(body.includes("[truncated"), "payload must carry a truncation marker"); + assert.ok(body.length < 500, "payload must be capped near the limit"); + } finally { + if (prev === undefined) delete process.env.EVENT_DATA_MAX_CHARS; + else process.env.EVENT_DATA_MAX_CHARS = prev; + } + }); + + it("does not inline payload for chat.message events", () => { + const msg = buildUserMessage({ type: "chat.message", chatSessionId: "s1", chatMessage: "hi" }); + assert.ok(!msg.includes("")); + assert.ok(msg.includes("hi")); + }); +}); + +describe("resolveEventDataMaxChars", () => { + it("returns the default when unset", () => { + const prev = process.env.EVENT_DATA_MAX_CHARS; + delete process.env.EVENT_DATA_MAX_CHARS; + try { + assert.equal(resolveEventDataMaxChars(), 16_000); + } finally { + if (prev !== undefined) process.env.EVENT_DATA_MAX_CHARS = prev; + } + }); + + it("ignores invalid values", () => { + const prev = process.env.EVENT_DATA_MAX_CHARS; + try { + process.env.EVENT_DATA_MAX_CHARS = "banana"; + assert.equal(resolveEventDataMaxChars(), 16_000); + process.env.EVENT_DATA_MAX_CHARS = "-5"; + assert.equal(resolveEventDataMaxChars(), 16_000); + process.env.EVENT_DATA_MAX_CHARS = "0"; + assert.equal(resolveEventDataMaxChars(), 16_000); + } finally { + if (prev === undefined) delete process.env.EVENT_DATA_MAX_CHARS; + else process.env.EVENT_DATA_MAX_CHARS = prev; + } + }); + + it("honours a valid override", () => { + const prev = process.env.EVENT_DATA_MAX_CHARS; + try { + process.env.EVENT_DATA_MAX_CHARS = "512"; + assert.equal(resolveEventDataMaxChars(), 512); + } finally { + if (prev === undefined) delete process.env.EVENT_DATA_MAX_CHARS; + else process.env.EVENT_DATA_MAX_CHARS = prev; + } + }); +}); + describe("toConversationPayloads", () => { const meta = { agentName: "olli", diff --git a/pi/pi-extensions/ainsel-runner/index.ts b/pi/pi-extensions/ainsel-runner/index.ts index ecc470f..fdd7582 100644 --- a/pi/pi-extensions/ainsel-runner/index.ts +++ b/pi/pi-extensions/ainsel-runner/index.ts @@ -21,6 +21,8 @@ * NAK_DELAY_MS default 60000 — NACK backoff (ms) before retry * TOOL_RESULT_MAX_CHARS default 16000 — per-text-block cap for toolResult * transcript content (chars, UTF-16 code units) + * EVENT_DATA_MAX_CHARS default 16000 — cap for the raw event payload + * inlined into the prompt as */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; @@ -51,6 +53,7 @@ const REDACTION_DENYLIST = new Set([ "PWD", "NAK_DELAY_MS", "TOOL_RESULT_MAX_CHARS", + "EVENT_DATA_MAX_CHARS", "POST_TIMEOUT_MS", "TURN_TIMEOUT_MS", "TURN_SETTLE_TIMEOUT_MS", @@ -122,6 +125,21 @@ export function resetRedactionCache(): void { /** Default per-text-block cap for toolResult content (chars). */ const TOOL_RESULT_MAX_CHARS_DEFAULT = 16_000; +const EVENT_DATA_MAX_CHARS_DEFAULT = 16_000; + +/** + * Resolve the raw-event-payload cap from the `EVENT_DATA_MAX_CHARS` env var, + * falling back to EVENT_DATA_MAX_CHARS_DEFAULT for unset/invalid values. + */ +export function resolveEventDataMaxChars(): number { + const env = process.env.EVENT_DATA_MAX_CHARS; + if (env) { + const n = parseInt(env, 10); + if (!Number.isNaN(n) && n > 0) return n; + } + return EVENT_DATA_MAX_CHARS_DEFAULT; +} + /** * Resolve the per-text-block cap from the `TOOL_RESULT_MAX_CHARS` env var, * read lazily at call time so tests can override it without module reload. @@ -525,7 +543,7 @@ function startMetricsServer() { }); } -function buildUserMessage(ctx: EventContext): string { +export function buildUserMessage(ctx: EventContext, payload?: unknown): string { if (ctx.type === "chat.message") { const lines = [ "", @@ -580,6 +598,14 @@ function buildUserMessage(ctx: EventContext): string { lines.push(""); lines.push(""); lines.push(`Handle the ${ctx.type ?? "event"} on ${ref}.`); + if (payload !== undefined && payload !== null) { + lines.push(""); + lines.push("The full raw event payload:"); + lines.push(""); + lines.push(""); + lines.push(truncateWithMarker(JSON.stringify(payload, null, 2), resolveEventDataMaxChars())); + lines.push(""); + } return lines.join("\n"); } @@ -924,7 +950,7 @@ async function processTask( const thisGeneration = tracker.activeGeneration; try { - await pi.sendUserMessage(buildUserMessage(evCtx)); + await pi.sendUserMessage(buildUserMessage(evCtx, task.payload)); // Wait for the turn to end, with a timeout to prevent hanging forever. let turnTimer: ReturnType | undefined;