-
Notifications
You must be signed in to change notification settings - Fork 654
Expand file tree
/
Copy pathotel.ts
More file actions
1694 lines (1584 loc) · 70.2 KB
/
Copy pathotel.ts
File metadata and controls
1694 lines (1584 loc) · 70.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* agenta-otel — a Pi extension that turns Pi's `pi.on(...)` lifecycle events into
* OpenTelemetry spans and exports them (OTLP/HTTP protobuf) to Agenta.
*
* This is the service build of the WP-1 POC extension
* (docs/design/agent-workflows/scratch/wp-1-pi-tracing/poc/agenta-otel.ts). It keeps the
* span tree and the load-bearing attribute choices identical, and adds three
* things the service needs that the single-run POC did not:
*
* 1. Per-run state. The POC kept span state in module globals because it ran one
* prompt at a time. The service may drive several runs in one process (the
* HTTP sidecar), so all per-run state lives in the closure returned by
* `createAgentaOtel`. The shared tracer/provider/exporters stay module-level.
* 2. Cross-boundary trace context. The caller (the Agenta Python service) passes a
* W3C `traceparent`. When present, `invoke_agent` is started as a CHILD of that
* remote span, so the whole agent run joins the same trace as the `/invoke`
* request — the agent's work becomes part of the response trace, the way
* completion/chat nest their LLM spans under the workflow span.
* 3. Per-trace export target. The OTLP endpoint and `Authorization` header come
* from the run config (the caller's host + credentials), falling back to env.
* Each trace is exported with its own target, so a shared process can serve
* more than one project.
*
* Span tree (per user prompt), unchanged from the POC:
* invoke_agent (openinference.span.kind = AGENT)
* turn N (CHAIN)
* chat <model> (LLM) — the provider request for that turn
* execute_tool <name> (TOOL) — each tool the turn ran
*
* Config (read lazily from the environment for the fallback target):
* AGENTA_API_INTERNAL_URL, AGENTA_API_URL — fallback exporter endpoint
* AGENTA_CREDENTIALS — per-run caller credential (no static API key)
* OTEL_SERVICE_NAME — resource service.name (default "pi-agent")
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
context,
createContextKey,
ROOT_CONTEXT,
trace,
TraceFlags,
SpanStatusCode,
type Context,
type Span,
type SpanContext,
} from "@opentelemetry/api";
import { ExportResultCode } from "@opentelemetry/core";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { resourceFromAttributes } from "@opentelemetry/resources";
import type {
ReadableSpan,
SpanExporter,
SpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import type { AgentEvent, AgentUsage, EmitEvent } from "../protocol.ts";
import type { Redactor } from "../redaction.ts";
/** Machine-readable prefix on a sibling force-settle result (see TOOL_NOT_EXECUTED_PAUSED). The
* responder keys off this to keep the deferral out of the client-output store, and the web widget
* keys off it to render the sibling as deferred rather than failed. */
export const DEFERRED_NOT_EXECUTED_PREFIX = "DEFERRED_NOT_EXECUTED";
export const TOOL_NOT_EXECUTED_PAUSED = `${DEFERRED_NOT_EXECUTED_PREFIX}: paused for another approval; retry the same call if still required.`;
export const APPROVED_EXECUTION_RESULT_UNKNOWN =
"APPROVED_EXECUTION_RESULT_UNKNOWN: the approved call started but its result was not observed before the pause ended the turn; do not assume it failed and do not retry a side-effecting call.";
/** Terminal result stamped on every open tool call when the USER stops (cancels) the turn. Unlike
* the pause sentinels this is a deliberate human halt, not a scheduling artifact: the call was cut
* off and may or may not have run, so the model must not silently retry it. Steer (stop + a new
* instruction) surfaces the user's guidance separately as the next turn's prompt. */
export const INTERRUPTED_BY_USER =
"INTERRUPTED_BY_USER: the user stopped the turn before this call finished; it may not have completed. Do not retry it unless the user asks again.";
// ---------------------------------------------------------------------------
// Shared, process-wide tracing infrastructure
// ---------------------------------------------------------------------------
/** Where a trace's spans are shipped: an OTLP endpoint and an Authorization header. */
interface ExportTarget {
endpoint: string;
authorization?: string;
}
/** Monotonic id identifying one run's spans within a (possibly shared) trace. */
let nextRunId = 0;
function mintRunId(): string {
return `run-${nextRunId++}`;
}
/** Context key carrying the owning run's id onto every span it starts (root and descendants). */
const RUN_ID_CONTEXT_KEY = createContextKey("agenta.otel.run_id");
function withRunId(ctx: Context, runId: string): Context {
return ctx.setValue(RUN_ID_CONTEXT_KEY, runId);
}
function runIdOf(ctx: Context): string | undefined {
return ctx.getValue(RUN_ID_CONTEXT_KEY) as string | undefined;
}
/**
* traceId (hex) -> runId -> where that run's spans should be exported. A distributed trace can be
* shared by concurrent runs (the caller's traceparent nests them all under the same trace id), and
* two runs sharing a trace may legitimately export to DIFFERENT targets (different caller
* endpoint/auth) — the target is a property of the RUN, not the trace. `registerRunTarget` adds a
* run's target on start, `releaseRunTarget` removes it once that run's spans are flushed. Mirrors
* `traceRedactors` below exactly, so the two per-trace accumulators never disagree about when a
* trace's state is dead.
*/
const traceTargets = new Map<string, Map<string, ExportTarget>>();
function registerRunTarget(
traceId: string,
runId: string,
target: ExportTarget,
): void {
let byRun = traceTargets.get(traceId);
if (!byRun) {
byRun = new Map();
traceTargets.set(traceId, byRun);
}
byRun.set(runId, target);
}
/** Drop one run's target from the trace's accumulator; the trace entry itself is only removed
* once no run remains registered (a later batch from another run may still export). */
function releaseRunTarget(traceId: string, runId: string): void {
const byRun = traceTargets.get(traceId);
if (!byRun) return;
byRun.delete(runId);
if (byRun.size === 0) traceTargets.delete(traceId);
}
/** spanId (hex) -> the runId that started it, so a flushed batch can be split per run and each
* sub-batch shipped to the target of the run that actually produced it. Entries are removed as
* spans are consumed by flush() so this never grows unbounded. */
const spanRunIds = new Map<string, string>();
/**
* traceId (hex) -> the deny-set of every RUN currently registered on that trace. A distributed
* trace can be shared by concurrent runs (the caller's traceparent nests them all under the same
* trace id), so this is an accumulator, not a single slot: `registerRunRedactor` adds a run's
* redactor on start, `releaseRunRedactor` removes it once that run's spans are flushed. A flush
* applies every redactor still registered for the trace, and the trace entry is only dropped once
* the registered set is empty — never on the first flush.
*/
const traceRedactors = new Map<string, Set<Redactor>>();
function registerRunRedactor(traceId: string, redactor: Redactor): void {
let set = traceRedactors.get(traceId);
if (!set) {
set = new Set();
traceRedactors.set(traceId, set);
}
set.add(redactor);
}
/** Drop one run's redactor from the trace's accumulator; the trace entry itself is only
* removed once no run remains registered (a later batch from another run may still export). */
function releaseRunRedactor(traceId: string, redactor: Redactor): void {
const set = traceRedactors.get(traceId);
if (!set) return;
set.delete(redactor);
if (set.size === 0) traceRedactors.delete(traceId);
}
/** Redact every string-valued span attribute, event attribute, and the status message in place
* (known-value pass; sink-level, right before export — same rationale as the persist.ts sink).
* Applies EVERY redactor registered for the trace, so overlapping runs' secrets are all caught.
* Fail-safe: redactString/redactJson never throw. */
function redactSpan(span: ReadableSpan, redactors: Iterable<Redactor>): void {
for (const redactor of redactors) {
redactAttributes(span.attributes as Record<string, unknown>, redactor);
for (const event of span.events) {
if (event.attributes) {
redactAttributes(event.attributes as Record<string, unknown>, redactor);
}
}
const status = span.status as { message?: string };
if (typeof status.message === "string") {
status.message = redactor.redactString(status.message, "spans") ?? status.message;
}
}
}
function redactAttributes(attrs: Record<string, unknown>, redactor: Redactor): void {
for (const [key, value] of Object.entries(attrs)) {
if (typeof value === "string") {
attrs[key] = redactor.redactString(value, "spans");
} else if (
Array.isArray(value) &&
value.every((v) => typeof v === "string")
) {
attrs[key] = value.map((v) => redactor.redactString(v, "spans"));
}
}
}
/** Cache one exporter per distinct endpoint+auth so we do not rebuild per export. */
const exporterCache = new Map<string, OTLPTraceExporter>();
function targetKey(target: ExportTarget): string {
return `${target.endpoint}\n${target.authorization ?? ""}`;
}
function getExporter(target: ExportTarget): OTLPTraceExporter {
const key = targetKey(target);
let exporter = exporterCache.get(key);
if (!exporter) {
exporter = new OTLPTraceExporter({
url: target.endpoint,
headers: target.authorization
? { Authorization: target.authorization }
: {},
timeoutMillis: 10_000,
});
exporterCache.set(key, exporter);
}
return exporter;
}
/** Fallback target from env, used when a trace was started without an explicit one. */
function defaultTarget(): ExportTarget {
// Internal direct hop first, then the public `.../api` base, then cloud.
const base =
(
process.env.AGENTA_API_INTERNAL_URL ?? process.env.AGENTA_API_URL
)?.replace(/\/+$/, "") || "https://cloud.agenta.ai/api";
// The per-run caller credential rides the request (each explicit trace target carries its own
// authorization; local Pi's OTLP bearer is written to a 0600 file). The runner holds no static
// platform key: it must not carry an `AGENTA_API_KEY` a local harness could read from /proc and
// reuse (interface.md section 2). The scheme-tagged ephemeral `AGENTA_CREDENTIALS` (a
// `Secret ...` from `/check`, used verbatim) is the only fallback; absent it, export unauthed.
const credentials = process.env.AGENTA_CREDENTIALS || "";
return {
endpoint: `${base}/otlp/v1/traces`,
authorization: credentials || undefined,
};
}
/**
* Buffer a trace's spans and export them in ONE OTLP batch. Agenta computes
* cumulative (rolled-up) token/cost metrics per ingest batch, so a trace split
* across batches loses the root aggregation. Two completion signals:
* - the root span ends (standalone run: invoke_agent IS the root), or
* - the run flushes explicitly by trace id (cross-boundary run: invoke_agent
* has a remote parent that never ends in this process, so root-end never fires).
*/
class TraceBatchProcessor implements SpanProcessor {
private readonly buffers = new Map<string, ReadableSpan[]>();
// Tag every span with the run id ambient in its start context (see `withRunId`), so a later
// flush can tell which run produced it — concurrent runs sharing a trace id may have DIFFERENT
// export targets, and a batch must go to the target of the run that produced it, not to
// whichever run happens to still be registered on the trace.
onStart(span: Span, parentContext: Context): void {
const runId = runIdOf(parentContext);
if (runId) spanRunIds.set(span.spanContext().spanId, runId);
}
onEnd(span: ReadableSpan): void {
const traceId = span.spanContext().traceId;
const spans = this.buffers.get(traceId) ?? [];
spans.push(span);
this.buffers.set(traceId, spans);
// No parent in this process => this is the local root and the trace is done.
if (!span.parentSpanContext?.spanId) {
this.flush(traceId);
}
}
/** Export and drop one trace's buffered spans, split into one sub-batch PER RUN and shipped to
* that run's own target (two runs sharing a trace id may have different endpoint/auth). Resolves
* once every sub-batch's export returns. Does NOT clear the trace's registered redactors or
* per-run targets for runs other than the ones this batch just exported — those live until each
* registered run releases (see `releaseRunRedactor`/`releaseRunTarget`), since a later batch on
* the same trace id can still be emitted by another still-running run sharing the trace. */
flush(traceId: string): Promise<void> {
const spans = this.buffers.get(traceId);
if (!spans || spans.length === 0) return Promise.resolve();
this.buffers.delete(traceId);
// Redact at the sink: the last point before the spans leave the process. Apply every
// redactor currently registered for this trace (concurrent runs sharing a trace id).
const redactors = traceRedactors.get(traceId);
if (redactors && redactors.size > 0)
for (const span of spans) redactSpan(span, redactors);
const byRun = traceTargets.get(traceId);
const groups = new Map<string | undefined, ReadableSpan[]>();
for (const span of spans) {
const spanId = span.spanContext().spanId;
const runId = spanRunIds.get(spanId);
spanRunIds.delete(spanId);
const group = groups.get(runId) ?? [];
group.push(span);
groups.set(runId, group);
}
return Promise.all(
[...groups.entries()].map(([runId, group]) => {
// Fall back to the env default only for a span whose OWN run's target is unknown
// (untagged span, or the run already released) — never to another run's target, or a
// batch could still land on an unintended endpoint/auth.
const target = (runId ? byRun?.get(runId) : undefined) ?? defaultTarget();
return new Promise<void>((resolve) => {
try {
getExporter(target).export(orderParentFirst(group), (result) => {
if (result.code === ExportResultCode.FAILED)
console.error(
"otel: trace export failed",
traceId,
result.error,
);
resolve();
});
} catch (err) {
// A synchronous export throw (e.g. misconfigured exporter) must stay best-effort:
// flush() is awaited without a catch, so a reject here would break the run.
console.error("otel: trace export threw", traceId, err);
resolve();
}
});
}),
).then(() => undefined);
}
forceFlush(): Promise<void> {
return Promise.all(
[...this.buffers.keys()].map((traceId) => this.flush(traceId)),
).then(() => undefined);
}
shutdown(): Promise<void> {
return this.forceFlush().then(async () => {
await Promise.all(
[...exporterCache.values()].map((exporter) => exporter.shutdown()),
);
});
}
}
let provider: NodeTracerProvider | undefined;
let processor: TraceBatchProcessor | undefined;
function ensureProvider(): void {
if (provider) return;
processor = new TraceBatchProcessor();
provider = new NodeTracerProvider({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || "pi-agent",
}),
spanProcessors: [processor],
});
provider.register();
}
/**
* Flush one trace's spans to Agenta. Call after a run whose root has a remote parent. `redactor`
* and `runId` are released from the trace's accumulators AFTER the export resolves — this run is
* done contributing spans, but other runs still registered on the same trace id (a shared
* distributed trace) keep their redactor/target live for later batches.
*/
export async function flushTrace(
traceId?: string,
redactor?: Redactor,
runId?: string,
): Promise<void> {
if (!processor || !traceId) return;
try {
await processor.flush(traceId);
} finally {
if (redactor) releaseRunRedactor(traceId, redactor);
if (runId) releaseRunTarget(traceId, runId);
}
}
/**
* Order spans parent-before-child (preorder DFS). Agenta stores timestamps at
* millisecond resolution and builds its roll-up tree by sorting on start_time,
* attaching a span only if its parent is already seen. A parent-first request
* order keeps parents ahead of children on same-millisecond ties.
*/
function orderParentFirst(spans: ReadableSpan[]): ReadableSpan[] {
const byId = new Map(spans.map((s) => [s.spanContext().spanId, s]));
const childrenOf = new Map<string, ReadableSpan[]>();
const roots: ReadableSpan[] = [];
for (const s of spans) {
const parentId = s.parentSpanContext?.spanId;
if (parentId && byId.has(parentId)) {
const list = childrenOf.get(parentId) ?? [];
list.push(s);
childrenOf.set(parentId, list);
} else {
roots.push(s);
}
}
const ordered: ReadableSpan[] = [];
const visit = (s: ReadableSpan) => {
ordered.push(s);
for (const child of childrenOf.get(s.spanContext().spanId) ?? [])
visit(child);
};
roots.forEach(visit);
// Any spans not reached (defensive) get appended so nothing is dropped.
if (ordered.length !== spans.length) {
const seen = new Set(ordered);
for (const s of spans) if (!seen.has(s)) ordered.push(s);
}
return ordered;
}
/** Build a parent Context from a W3C traceparent string, or undefined if absent/invalid. */
function parentContext(traceparent?: string): Context | undefined {
if (!traceparent) return undefined;
const match = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(
traceparent.trim(),
);
if (!match) return undefined;
const [, traceId, spanId, flags] = match;
const spanContext: SpanContext = {
traceId,
spanId,
// Honor the incoming sampled bit; default to sampled so child spans record.
traceFlags:
(parseInt(flags, 16) & 1) === 1 ? TraceFlags.SAMPLED : TraceFlags.NONE,
isRemote: true,
};
return trace.setSpanContext(ROOT_CONTEXT, spanContext);
}
// ---------------------------------------------------------------------------
// Per-run config + content helpers
// ---------------------------------------------------------------------------
/** One run's tracing config. Mutated by the runner after the session is created. */
export interface RunConfig {
/** OTLP traces endpoint for this run's trace (falls back to env). */
endpoint?: string;
/** Authorization header value for the OTLP export (falls back to env ApiKey). */
authorization?: string;
/** W3C traceparent from the caller; nests invoke_agent under that span. */
traceparent?: string;
/** W3C baggage from the caller (carried for future use). */
baggage?: string;
/** Drop prompt/completion/tool I/O from spans when false. */
captureContent: boolean;
/** Pi session id, set after createAgentSession so spans carry session.id. */
sessionId?: string;
/** Resolved provider, set after the model is picked. */
provider?: string;
/** Resolved model id, set after the model is picked. */
requestModel?: string;
/**
* Skill names materialized for this run (author + forced `_agenta.*`), stamped on the agent
* span so a trace shows which skills loaded (F-029). Set on the local-Pi path, where Pi's own
* extension owns the agent span (the runner's sandbox-agent otel is span-less there).
*/
skills?: string[];
/** Per-run known-value redactor; scrubs the run's live secrets from exported spans. */
redactor?: Redactor;
/** Filled by the extension on agent_start so the runner can flush/return it. */
traceId?: string;
}
/** A string output → ag.data.outputs (any type is valid there). */
function setOutput(span: Span, value: unknown, capture: boolean): void {
if (!capture || value == null) return;
const text = typeof value === "string" ? value : JSON.stringify(value);
if (text.length > 0) span.setAttribute("output.value", text);
}
/**
* ag.data.inputs must be a dict, so emit input.value as a JSON object string.
* A non-object (raw string) would be relocated to ag.unsupported by Agenta.
*/
function setInputs(
span: Span,
obj: Record<string, unknown>,
capture: boolean,
): void {
if (!capture) return;
span.setAttribute("input.value", JSON.stringify(obj));
span.setAttribute("input.mime_type", "application/json");
}
function oiRole(role: string): string {
return role === "toolResult" ? "tool" : role; // user | assistant | system | tool
}
function messageText(msg: any): string {
const c = msg?.content;
if (typeof c === "string") return c;
if (Array.isArray(c)) {
return c
.filter((b: any) => b?.type === "text")
.map((b: any) => b.text)
.join("");
}
return "";
}
/**
* Emit OpenInference structured messages so Agenta renders a proper message
* thread. `llm.input_messages.*` -> ag.data.inputs.prompt.*,
* `llm.output_messages.*` -> ag.data.outputs.completion.*.
*/
function emitMessages(
span: Span,
prefix: string,
messages: any[],
capture: boolean,
): void {
if (!capture || !Array.isArray(messages)) return;
messages.forEach((m, i) => {
const base = `${prefix}.${i}.message`;
span.setAttribute(`${base}.role`, oiRole(m.role));
const text = messageText(m);
if (text) span.setAttribute(`${base}.content`, text);
if (m.role === "toolResult" && m.toolCallId)
span.setAttribute(`${base}.tool_call_id`, m.toolCallId);
if (Array.isArray(m.content)) {
m.content
.filter((b: any) => b?.type === "toolCall")
.forEach((call: any, j: number) => {
const tc = `${base}.tool_calls.${j}.tool_call`;
if (call.id) span.setAttribute(`${tc}.id`, call.id);
span.setAttribute(`${tc}.function.name`, call.name);
span.setAttribute(
`${tc}.function.arguments`,
JSON.stringify(call.arguments ?? {}),
);
});
}
});
}
function toolResultText(result: any): string {
if (!result) return "";
if (typeof result === "string") return result;
if (Array.isArray(result)) {
return result
.filter((c: any) => c?.type === "text")
.map((c: any) => c.text)
.join("");
}
if (result.content) return toolResultText(result.content);
return JSON.stringify(result);
}
function lastAssistantText(messages: any): string {
if (!Array.isArray(messages)) return "";
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]?.role === "assistant") return messageText(messages[i]);
}
return "";
}
/** Fill an LLM span from a finished assistant message (model, tokens, finish, output). */
/** Returns the error message when the assistant turn failed (stopReason/errorMessage), else
* undefined — so the caller can emit a matching `error` event, not just stamp the span. */
function applyAssistant(
span: Span,
msg: any,
capture: boolean,
): string | undefined {
if (msg.provider) span.setAttribute("gen_ai.system", msg.provider);
if (msg.model) span.setAttribute("gen_ai.request.model", msg.model);
if (msg.responseModel || msg.model)
span.setAttribute("gen_ai.response.model", msg.responseModel ?? msg.model);
if (msg.responseId) span.setAttribute("gen_ai.response.id", msg.responseId);
if (msg.stopReason)
span.setAttribute("gen_ai.response.finish_reasons", [
String(msg.stopReason),
]);
const u = msg.usage;
if (u) {
// Current GenAI names (mapped by Agenta's logfire adapter) ...
span.setAttribute("gen_ai.usage.input_tokens", u.input ?? 0);
span.setAttribute("gen_ai.usage.output_tokens", u.output ?? 0);
// ... and legacy names (mapped by Agenta's semconv.py). Emit both so token
// usage is never silently dropped regardless of which adapter wins.
span.setAttribute("gen_ai.usage.prompt_tokens", u.input ?? 0);
span.setAttribute("gen_ai.usage.completion_tokens", u.output ?? 0);
span.setAttribute(
"gen_ai.usage.total_tokens",
u.totalTokens ?? (u.input ?? 0) + (u.output ?? 0),
);
// Dotted form: matches logfire_adapter.py's ingest keys (underscore form was never read).
// Nullish check, not truthy, so a real 0 is emitted like the other token fields.
if (u.cacheRead != null)
span.setAttribute("gen_ai.usage.cache_read.input_tokens", u.cacheRead);
if (u.cacheWrite != null)
span.setAttribute(
"gen_ai.usage.cache_creation.input_tokens",
u.cacheWrite,
);
if (u.cost?.total != null)
span.setAttribute("gen_ai.usage.cost", u.cost.total);
}
emitMessages(span, "llm.output_messages", [msg], capture);
if (msg.stopReason === "error" || msg.errorMessage) {
span.setStatus({ code: SpanStatusCode.ERROR, message: msg.errorMessage });
return String(msg.errorMessage || "agent run failed");
}
return undefined;
}
// ---------------------------------------------------------------------------
// Extension factory (one per run; state is closure-scoped)
// ---------------------------------------------------------------------------
export interface AgentaOtel {
/** Register with DefaultResourceLoader.extensionFactories. */
register: (pi: ExtensionAPI) => void;
/** Mutable config; set sessionId/provider/requestModel after the session exists. */
config: RunConfig;
/** Flush this run's trace to Agenta. Await before the process/response ends. */
flush: () => Promise<void>;
/** Run totals (tokens + cost) summed across turns, for roll-up onto the parent. */
usage: () => { input: number; output: number; total: number; cost: number };
}
/**
* Build a tracing extension scoped to a single agent run. Pass `register` to the
* resource loader, fill in `config.sessionId`/`provider`/`requestModel` once the
* session and model are resolved, then `await flush()` after the prompt completes.
*/
export function createAgentaOtel(
init: Partial<RunConfig> & { captureContent?: boolean },
): AgentaOtel {
ensureProvider();
const config: RunConfig = {
endpoint: init.endpoint,
authorization: init.authorization,
traceparent: init.traceparent,
captureContent: init.captureContent !== false,
sessionId: init.sessionId,
provider: init.provider,
requestModel: init.requestModel,
skills: init.skills,
redactor: init.redactor,
};
const tracer = trace.getTracer("agenta-pi-otel", "0.1.0");
const runId = mintRunId();
// Per-run span state — closure-scoped so concurrent runs never collide.
let agentSpan: Span | undefined;
let agentCtx: Context | undefined;
let pendingPrompt: string | undefined;
let currentTurn: { span: Span; ctx: Context; index?: number } | undefined;
let llmSpan: Span | undefined;
let lastContextMessages: any[] | undefined;
const toolSpans = new Map<string, Span>();
// Run totals, summed across every assistant turn. Stamped on the agent span and
// returned so the caller can roll them up onto the workflow span in its own process
// (the agent and workflow spans are exported in separate OTLP batches, so Agenta's
// per-batch cumulative roll-up cannot bridge them on its own).
const runUsage = { input: 0, output: 0, total: 0, cost: 0 };
function accumulateUsage(msg: any): void {
const u = msg?.usage;
if (!u) return;
const input = u.input ?? 0;
const output = u.output ?? 0;
runUsage.input += input;
runUsage.output += output;
runUsage.total += u.totalTokens ?? input + output;
if (u.cost?.total != null) runUsage.cost += u.cost.total;
}
const register = (pi: ExtensionAPI): void => {
pi.on("before_agent_start", async (event: any) => {
pendingPrompt = event?.prompt;
});
pi.on("agent_start", async () => {
// Nest under the caller's workflow span when a traceparent was supplied,
// so the whole run joins the /invoke trace; otherwise start a fresh root.
// Tag the run id onto the start context BEFORE creating the root span, so onStart
// attributes invoke_agent itself (and every descendant) to this run.
const parent = withRunId(
parentContext(config.traceparent) ?? context.active(),
runId,
);
agentSpan = tracer.startSpan("invoke_agent", undefined, parent);
agentSpan.setAttribute("openinference.span.kind", "AGENT");
agentSpan.setAttribute("gen_ai.operation.name", "invoke_agent");
agentSpan.setAttribute("gen_ai.agent.name", "pi");
// F-029/F-036: record which skills loaded on Pi's own agent span under the recognized
// `ag.meta.*` namespace, so a local-Pi trace shows the surfaced skills (not just the author
// config echoed elsewhere) AND Agenta's OTel ingest keeps them in a first-class `ag.*` bucket
// rather than relocating an unrecognized `ag.agent.*` key to `ag.unsupported.*`. The set is
// passed from the runner via AGENTA_AGENT_SKILLS_LOADED.
if (config.skills && config.skills.length > 0) {
agentSpan.setAttribute("ag.meta.skills.loaded", config.skills);
agentSpan.setAttribute("ag.meta.skills.count", config.skills.length);
}
if (config.sessionId) {
agentSpan.setAttribute("session.id", config.sessionId);
agentSpan.setAttribute("gen_ai.conversation.id", config.sessionId);
}
setInputs(
agentSpan,
{ prompt: pendingPrompt ?? "" },
config.captureContent,
);
const traceId = agentSpan.spanContext().traceId;
config.traceId = traceId;
registerRunTarget(traceId, runId, {
endpoint: config.endpoint ?? defaultTarget().endpoint,
authorization: config.authorization ?? defaultTarget().authorization,
});
if (config.redactor) registerRunRedactor(traceId, config.redactor);
agentCtx = trace.setSpan(parent, agentSpan);
});
// The messages handed to the next LLM call — the chat span's input.
pi.on("context", async (event: any) => {
if (Array.isArray(event?.messages)) lastContextMessages = event.messages;
});
pi.on("turn_start", async (event: any) => {
const parent = agentCtx ?? context.active();
const name =
event?.turnIndex != null ? `turn ${event.turnIndex}` : "turn";
const span = tracer.startSpan(name, undefined, parent);
span.setAttribute("openinference.span.kind", "CHAIN");
if (event?.turnIndex != null)
span.setAttribute("pi.turn.index", event.turnIndex);
currentTurn = {
span,
ctx: trace.setSpan(parent, span),
index: event?.turnIndex,
};
});
pi.on("before_provider_request", async (_event: any, ctx: any) => {
const parent = currentTurn?.ctx ?? agentCtx ?? context.active();
const modelId = config.requestModel ?? ctx?.model?.id;
const providerName = config.provider ?? ctx?.model?.provider;
llmSpan = tracer.startSpan(
modelId ? `chat ${modelId}` : "chat",
undefined,
parent,
);
llmSpan.setAttribute("openinference.span.kind", "LLM");
llmSpan.setAttribute("gen_ai.operation.name", "chat");
if (providerName) llmSpan.setAttribute("gen_ai.system", providerName);
if (modelId) llmSpan.setAttribute("gen_ai.request.model", modelId);
if (lastContextMessages)
emitMessages(
llmSpan,
"llm.input_messages",
lastContextMessages,
config.captureContent,
);
});
pi.on("message_end", async (event: any) => {
const msg = event?.message;
if (!msg || msg.role !== "assistant" || !llmSpan) return;
applyAssistant(llmSpan, msg, config.captureContent);
accumulateUsage(msg);
llmSpan.end();
llmSpan = undefined;
});
pi.on("tool_execution_start", async (event: any) => {
const parent = currentTurn?.ctx ?? agentCtx ?? context.active();
const name = event?.toolName
? `execute_tool ${event.toolName}`
: "execute_tool";
const span = tracer.startSpan(name, undefined, parent);
span.setAttribute("openinference.span.kind", "TOOL");
span.setAttribute("gen_ai.operation.name", "execute_tool");
if (event?.toolName)
span.setAttribute("gen_ai.tool.name", event.toolName);
if (event?.toolCallId)
span.setAttribute("gen_ai.tool.call.id", event.toolCallId);
setInputs(
span,
(event?.args as Record<string, unknown>) ?? {},
config.captureContent,
);
if (event?.toolCallId) toolSpans.set(event.toolCallId, span);
});
pi.on("tool_execution_end", async (event: any) => {
const span = event?.toolCallId
? toolSpans.get(event.toolCallId)
: undefined;
if (!span) return;
setOutput(span, toolResultText(event?.result), config.captureContent);
if (event?.isError) span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
toolSpans.delete(event.toolCallId);
});
pi.on("turn_end", async (event: any) => {
// Safety net: if the LLM span is still open (no assistant message_end seen),
// close it from the turn's assistant message.
if (llmSpan && event?.message) {
applyAssistant(llmSpan, event.message, config.captureContent);
accumulateUsage(event.message);
llmSpan.end();
llmSpan = undefined;
}
if (currentTurn) {
currentTurn.span.end();
currentTurn = undefined;
}
});
pi.on("agent_end", async (event: any) => {
if (!agentSpan) return;
setOutput(
agentSpan,
lastAssistantText(event?.messages),
config.captureContent,
);
// Stamp the run total on the agent span so it shows the agent's tokens/cost even
// though Agenta cannot roll the per-turn LLM spans up across batches.
if (runUsage.total > 0) {
agentSpan.setAttribute("gen_ai.usage.input_tokens", runUsage.input);
agentSpan.setAttribute("gen_ai.usage.output_tokens", runUsage.output);
agentSpan.setAttribute("gen_ai.usage.prompt_tokens", runUsage.input);
agentSpan.setAttribute(
"gen_ai.usage.completion_tokens",
runUsage.output,
);
agentSpan.setAttribute("gen_ai.usage.total_tokens", runUsage.total);
if (runUsage.cost > 0)
agentSpan.setAttribute("gen_ai.usage.cost", runUsage.cost);
}
agentSpan.end();
agentSpan = undefined;
agentCtx = undefined;
lastContextMessages = undefined;
});
};
return {
register,
config,
flush: () => flushTrace(config.traceId, config.redactor, runId),
usage: () => ({ ...runUsage }),
};
}
// ---------------------------------------------------------------------------
// sandbox-agent / ACP tracer (one per run; state is closure-scoped)
// ---------------------------------------------------------------------------
//
// The Pi extension above hooks Pi's in-process `pi.on(...)` events. Under sandbox-agent the
// harness runs as a separate process and we never see those events; instead the sandbox-agent
// SDK surfaces the run as ACP `session/update` notifications (agent_message_chunk,
// tool_call, tool_call_update, usage_update). This tracer builds the SAME span tree
// from that event stream, so tracing is uniform across every harness sandbox-agent drives
// (Pi, Claude Code, ...) and always nests under the caller's `/invoke` span.
//
// Span tree (per prompt turn):
// invoke_agent (AGENT)
// turn 0 (CHAIN)
// chat <model> (LLM) — model interaction; usage where the harness reports it
// execute_tool <n> (TOOL) — one per ACP tool_call
/** Text of an ACP ContentBlock (the shape carried by message/thought chunks). */
function acpBlockText(block: any): string {
if (!block) return "";
if (typeof block === "string") return block;
if (block.type === "text" && typeof block.text === "string")
return block.text;
return "";
}
/** Serialized form of real tool args, for change detection; undefined when absent/`{}`. */
function toolInputJson(input: unknown): string | undefined {
if (!hasToolArgs(input)) return undefined;
try {
return JSON.stringify(input);
} catch {
return undefined;
}
}
/**
* Whether a tool's `rawInput` holds real, inspectable args. A harness can announce a call with
* an absent or empty `{}` input and fill the args in on a later `tool_call_update` (Pi does);
* both placeholders count as "no args yet" so we know to refresh the tool_call once the real
* args land. Purely shape-based — no harness-specific logic.
*/
function hasToolArgs(input: unknown): boolean {
if (input == null) return false;
if (
typeof input === "object" &&
!Array.isArray(input) &&
Object.keys(input as Record<string, unknown>).length === 0
)
return false;
return true;
}
/** Text of an ACP tool_call `content` array (ToolCallContent[]). */
function acpToolContentText(content: any): string {
if (!content) return "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((c: any) => acpBlockText(c?.content ?? c))
.filter(Boolean)
.join("");
}
return "";
}
/** JSON of a value, treating "no information" serializations as absent. */
function jsonText(value: unknown): string {
try {
const text = JSON.stringify(value);
if (!text || text === "{}" || text === "[]" || text === "null") return "";
return text;
} catch {
return "";
}
}
/**
* Text of an OBJECT `rawOutput`. codex-acp never sends a `content` array on the update that
* completes a tool call — it sends a plain object whose shape depends on the tool: shell/exec
* `{formatted_output, exit_code}`, an MCP call `{result, error}` (result being an MCP
* CallToolResult carrying `content[]`), the unified exec path `{output}`. `acpToolContentText`
* reads none of those and returns "", which is why every codex tool result — successes and
* failures alike — stored and streamed empty.
*
* Candidates are tried in that order and an empty one falls through to the next, so a failed MCP
* call (`{result: null, error: "..."}`) carries its real error message; an object with no known
* key serializes whole, so a completed call is never silently empty.
*/
function acpRawOutputText(raw: any): string {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "";
for (const key of ["formatted_output", "result", "output", "error"]) {
const value = raw[key];
if (typeof value === "string") {
if (value) return value;
continue;
}
if (value && typeof value === "object") {
const text = acpToolContentText(value.content) || acpToolContentText(value);
if (text) return text;
const json = jsonText(value);
if (json) return json;
}
}
return jsonText(raw);
}
/**
* Is this line part of the pi-acp startup banner that some setups emit as the first agent
* message chunk, ahead of the real answer? pi-acp's `buildStartupInfo` produces, in order:
*
* pi v0.79.4
* ---
* (blank)
* ## Context
* - /tmp/agenta-sandbox-agent-XXXX/AGENTS.md
* (blank)
* ## Skills (when skills are installed)
* - /path/to/skill.md
* (blank)
* New version available: v0.80.2 (installed v0.79.4). Run: `npm i -g @earendil-works/pi-coding-agent`
*
* The markdown markers (`## `, `- `) are stripped when the playground renders the text, so the
* user sees a bare `Context` heading and an unprefixed absolute `.../AGENTS.md` path — but the
* raw chunk still carries the markdown, so we match BOTH the raw and the rendered shapes. The
* "New version available" notice is emitted even when `quietStartup` suppresses the rest, so it
* must be matched on its own. We only ever strip a LEADING run of these lines, so a genuine
* answer that happens to contain such words later is never touched.
*/
export function isBannerLine(line: string): boolean {
const t = line.trim();
return (
t === "" ||
t === "---" ||
/^pi v\d+\.\d+\.\d+\b/.test(t) ||
// section heading, raw ("## Context") or rendered ("Context"); same for "Skills"
/^(?:#{1,6}\s*)?(?:Context|Skills|Extensions)\s*$/.test(t) ||
// an AGENTS.md / *.md path item, list-prefixed ("- /…/AGENTS.md") or bare ("/…/AGENTS.md")
/^(?:-\s+)?\/\S*\.(?:md|js)\s*$/.test(t) ||
// upgrade notice: "New version available: vX (installed vY). Run: `npm i -g …`"
/^New version available:/.test(t) ||