Skip to content
Draft
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
16 changes: 13 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
initTelemetry,
wrapToolWithOtel,
startSessionSpan,
startTurnTrace,
recordGenAiCall,
shutdownTelemetry,
} from "./telemetry.js";
Expand All @@ -52,6 +53,7 @@ interface ParsedArgs {
repo?: string;
pat?: string;
session?: string;
sessionId?: string;
voice?: string;
}

Expand All @@ -67,6 +69,7 @@ function parseArgs(argv: string[]): ParsedArgs {
let repo: string | undefined;
let pat: string | undefined;
let session: string | undefined;
let sessionId: string | undefined;
let voice: string | undefined;

for (let i = 0; i < args.length; i++) {
Expand Down Expand Up @@ -107,6 +110,11 @@ function parseArgs(argv: string[]): ParsedArgs {
case "--session":
session = args[++i];
break;
// Distinct from --session (a git branch for repo/sandbox mode): this is
// the id carried on model requests so a gateway can group the run.
case "--session-id":
sessionId = args[++i];
break;
case "--voice":
case "-v":
// Accept optional backend name: --voice, --voice openai, --voice gemini
Expand All @@ -124,7 +132,7 @@ function parseArgs(argv: string[]): ParsedArgs {
}
}

return { model, dir, prompt, env, sandbox, sandboxRepo, sandboxToken, repo, pat, session, voice };
return { model, dir, prompt, env, sandbox, sandboxRepo, sandboxToken, repo, pat, session, sessionId, voice };
}

function handleEvent(
Expand Down Expand Up @@ -321,7 +329,7 @@ async function main(): Promise<void> {
return;
}

const { model, dir: rawDir, prompt, env, sandbox: useSandbox, sandboxRepo, sandboxToken, repo, pat, session: sessionBranch, voice } = parseArgs(process.argv);
const { model, dir: rawDir, prompt, env, sandbox: useSandbox, sandboxRepo, sandboxToken, repo, pat, session: sessionBranch, sessionId: sessionIdFlag, voice } = parseArgs(process.argv);

// If --repo is given, derive a default dir from the repo URL (skip interactive prompt)
let dir = rawDir;
Expand Down Expand Up @@ -465,7 +473,7 @@ async function main(): Promise<void> {

let loaded;
try {
loaded = await loadAgent(dir, model, env);
loaded = await loadAgent(dir, model, env, sessionIdFlag);
} catch (err: any) {
console.error(red(`Error: ${err.message}`));
process.exit(1);
Expand Down Expand Up @@ -643,6 +651,7 @@ async function main(): Promise<void> {
// Single-shot mode
if (prompt) {
try {
startTurnTrace(loaded.model);
await otelContext.with(_session.ctx, () => agent.prompt(prompt));
} catch (err: any) {
auditLogger?.logError(err.message).catch(() => {});
Expand Down Expand Up @@ -804,6 +813,7 @@ async function main(): Promise<void> {
}

try {
startTurnTrace(loaded.model);
await otelContext.with(_session.ctx, () => agent.prompt(promptText));
} catch (err: any) {
console.error(red(`Error: ${err.message}`));
Expand Down
22 changes: 19 additions & 3 deletions src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,10 @@ async function ensureGitagentDir(agentDir: string): Promise<string> {
return gitagentDir;
}

async function writeSessionState(gitagentDir: string): Promise<string> {
const sessionId = randomUUID();
async function writeSessionState(gitagentDir: string, override?: string): Promise<string> {
// A caller-supplied id wins so an embedding host (Studio, a web UI, a test)
// can tie this run to a session it already knows about.
const sessionId = override || randomUUID();
const state = {
session_id: sessionId,
started_at: new Date().toISOString(),
Expand Down Expand Up @@ -239,6 +241,7 @@ export async function loadAgent(
agentDir: string,
modelFlag?: string,
envFlag?: string,
sessionIdOverride?: string,
): Promise<LoadedAgent> {
// Parse agent.yaml
const manifestRaw = await readFile(join(agentDir, "agent.yaml"), "utf-8");
Expand All @@ -249,7 +252,7 @@ export async function loadAgent(

// Ensure .gitagent/ directory and write session state
const gitagentDir = await ensureGitagentDir(agentDir);
const sessionId = await writeSessionState(gitagentDir);
const sessionId = await writeSessionState(gitagentDir, sessionIdOverride);

// Resolve inheritance (Phase 2.4)
let parentRules = "";
Expand Down Expand Up @@ -405,6 +408,19 @@ Do NOT track trivial single-command tasks (e.g. "what time is it"). But DO check
model = getModel(provider as any, modelId as any);
}

// One run is many model requests: every turn of the agent loop, plus the
// off-loop reflection, repair and compaction calls. A gateway that groups
// telemetry per request sees each of those as a separate session unless the
// client says otherwise, so carry this run's id on every request.
//
// Cloned rather than mutated — getModel() returns a shared registry object,
// and writing to it would leak this run's id into every other model built in
// the same process.
model = {
...model,
headers: { ...(model as any).headers, "X-Session-Id": sessionId },
};

// For custom providers not in pi-ai's env key map, ensure an API key is available.
// pi-ai calls getEnvApiKey(model.provider) which only knows built-in providers.
// For unknown providers using openai-completions API, set provider to "openai" so
Expand Down
8 changes: 7 additions & 1 deletion src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { context as otelContext } from "@opentelemetry/api";
import {
wrapToolWithOtel,
startSessionSpan,
startTurnTrace,
recordGenAiCall,
} from "./telemetry.js";

Expand Down Expand Up @@ -151,7 +152,10 @@ export function query(options: QueryOptions): Query {
}

// 1. Load agent
const loaded = await loadAgent(dir, options.model, options.env);
// options.sessionId, when given, becomes the agent's session id — so a host
// that already tracks a conversation sees its own id on the model requests
// rather than a fresh one per run.
const loaded = await loadAgent(dir, options.model, options.env, options.sessionId);
_manifest = loaded.manifest;
_sessionId = _sessionId || loaded.sessionId;

Expand Down Expand Up @@ -515,6 +519,7 @@ export function query(options: QueryOptions): Query {
return;
}
}
startTurnTrace(loaded.model);
await otelContext.with(_session.ctx, () =>
agent.prompt(options.prompt as string),
);
Expand All @@ -539,6 +544,7 @@ export function query(options: QueryOptions): Query {
return;
}
}
startTurnTrace(loaded.model);
await otelContext.with(_session.ctx, () =>
agent.prompt(userMsg.content),
);
Expand Down
28 changes: 28 additions & 0 deletions src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
Counter,
} from "@opentelemetry/api";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { randomBytes } from "crypto";

// ── Public types ───────────────────────────────────────────────────────

Expand Down Expand Up @@ -184,6 +185,33 @@ export function isTelemetryEnabled(): boolean {
return _initialized;
}

// ── Turn-scoped trace propagation ──────────────────────────────────────

/**
* Start a new W3C trace for one user turn.
*
* A single user message costs several HTTP calls to the model gateway — one
* that comes back with a tool call, another with the answer, and so on. Each
* call is a separate request, so a gateway that traces per request records one
* trace per call and the turn arrives split across several of them. Sending the
* same `traceparent` on every call of the turn lets the gateway stitch them
* into one trace.
*
* No-op once telemetry is initialised: the undici instrumentation already
* injects `traceparent` from the active span, and a header written here would
* fight it.
*/
export function startTurnTrace(model: unknown): void {
try {
if (_initialized || !model) return;
const m = model as { headers?: Record<string, string> };
const traceparent = `00-${randomBytes(16).toString("hex")}-${randomBytes(8).toString("hex")}-01`;
m.headers = { ...(m.headers ?? {}), traceparent };
} catch {
// Telemetry must never break a run.
}
}

// ── Tracer / meter accessors ───────────────────────────────────────────

export function getTracer(): Tracer {
Expand Down