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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ console.log(result.usage.totalTokens, result.finishReason, result.steps);
- [Configuration](docs/configuration.md) - agent spec fields, provider env vars, `AgentExecutor.execute()` options, CLI flags
- [Deployment](docs/deployment.md) - `loushy build` targets: Node server, Docker, Cloudflare Workers
- [API Overview](docs/api-overview.md) - the main exports; `npm run docs:build` generates the full TypeDoc reference
- [Tracing and observability](docs/observability.md) - OpenTelemetry GenAI spans, attribute table, content opt-in
- [Testing](docs/testing.md) - unit-test agents deterministically with the scripted `mockModel`
- [Skills](docs/skills.md) - on-demand instructions: `defineSkill()`, `loadSkills()`, how they save context
- [Agent Forge](docs/agent-forge.md) - the visual dashboard (`loushy studio`): quickstart, first-agent walkthrough, hook authoring
Expand Down Expand Up @@ -447,6 +448,12 @@ await AgentExecutor.execute({
});
```

Spans follow the OpenTelemetry GenAI semantic conventions (`invoke_agent`,
`chat {model}`, `execute_tool {tool}`; flows are traced too). Message and
tool-argument content is only recorded with `captureContent: true`. See
[Tracing and observability](docs/observability.md) for the attribute table,
the opt-in and the deprecated pre-GenAI names.

Ready-made exporters live in [examples/tracing](examples/tracing) (console
and real OpenTelemetry bridges).

Expand Down
8 changes: 5 additions & 3 deletions apps/agent-forge/server/__tests__/runRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,15 @@ describe('RunManager', () => {
await runManager.run('agent-spans', 'please use current-date', SPEC);
await waitForStatus(runManager, 'agent-spans', (s) => s.status === 'stopped');

const runSpans = spans.filter((s) => s.name === 'agent.run');
const runSpans = spans.filter((s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent');
expect(runSpans).toHaveLength(2); // start + end
const [startSpan, endSpan] = runSpans;
expect(startSpan.endTime).toBeUndefined();
expect(endSpan.endTime).toBeGreaterThanOrEqual(endSpan.startTime);
expect(spans.some((s) => s.name === 'llm.generate')).toBe(true);
expect(spans.some((s) => s.name === 'tool.call' && s.parentId === startSpan.id)).toBe(true);
expect(spans.some((s) => s.attributes['gen_ai.operation.name'] === 'chat')).toBe(true);
expect(
spans.some((s) => s.attributes['gen_ai.operation.name'] === 'execute_tool' && s.parentId === startSpan.id)
).toBe(true);
});

it('pauses a run at a configured breakpoint and resumes on continueRun() (O3)', async () => {
Expand Down
9 changes: 7 additions & 2 deletions apps/agent-forge/src/components/debug/TracePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ import type { AgentGraphSpec } from '../../graph/types';
import { findLlmNodeId, findToolNodeId } from './nodeLookup';

function nodeIdForSpan(graph: AgentGraphSpec, span: SpanEvent): string | undefined {
if (span.name === 'llm.generate') return findLlmNodeId(graph);
if (span.name === 'tool.call') return findToolNodeId(graph, span.attributes.toolName as string | undefined);
// SDK spans follow the OpenTelemetry GenAI conventions (LOU-D9): the operation is
// `chat` (model call) or `execute_tool` (tool call).
const operation = span.attributes['gen_ai.operation.name'];
if (operation === 'chat') return findLlmNodeId(graph);
if (operation === 'execute_tool') {
return findToolNodeId(graph, span.attributes['gen_ai.tool.name'] as string | undefined);
}
return undefined;
}

Expand Down
4 changes: 3 additions & 1 deletion docs/api-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,9 @@ The built-in context windows and prices are a dated snapshot (see the retrieval
`createOtelTraceExporter()` from the `@loushy/build-ai-agent/otel`
subpath (requires the optional peer dependency `@opentelemetry/api`)
instead of hand-rolling the OTel bridge - see
`examples/tracing/run-otel.ts`.
`examples/tracing/run-otel.ts`. Spans follow the OpenTelemetry GenAI
semantic conventions (flows are traced too); see
[observability](observability.md).
- `NoopSandbox` / `SubprocessSandbox` - sandboxing for tools that opt in via
`requiresSandbox`; guardrails such as `createCommandGuardrail()` and
`secretScanGuardrail`.
Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ dependency prints the exact `npm install` command.
| `onEvent` | Callback for execution events (`start`, `tool-call`, `finish`, ...). |
| `approvalStore`, `sessionId` | Human-in-the-loop approvals (see `resumeAfterApproval()`). |
| `checkpointStore` | Persist/resume execution checkpoints. |
| `exporter` | A `TraceExporter` for tracing spans. |
| `exporter` | A `TraceExporter` for tracing spans (OpenTelemetry GenAI conventions, see [observability](observability.md)). |
| `captureContent`, `redactContent` | Record message/tool content on `gen_ai.*` span attributes (opt-in) / omit the deprecated content attributes. |
| `onLLMRequest`, `onLLMResponse`, `onToolCall`, `onToolResult` | Observability hooks. |

It resolves to an `ExecutionResult`: `{ text, messages, toolCalls, usage,
Expand Down
180 changes: 180 additions & 0 deletions docs/observability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# Tracing and observability

`AgentExecutor.execute()` and `FlowExecutor.execute()` emit spans through a
`TraceExporter`. Span names and attributes follow the
[OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/),
so GenAI-aware observability backends can recognise them without custom
mapping.

> **Stability.** The GenAI conventions are in `Development` status (not
> stable). Attribute names have changed between versions of the spec before
> (for example `gen_ai.system` became `gen_ai.provider.name`) and may change
> again. This SDK follows the spec as retrieved on 2026-10-01 from the
> [semantic-conventions-genai repository](https://github.com/open-telemetry/semantic-conventions-genai)
> (where the conventions now live). Pin your SDK version if dashboards depend
> on these names.

## Quick start

```ts
import { AgentExecutor, type TraceExporter } from '@loushy/build-ai-agent';

const exporter: TraceExporter = {
onSpanStart: (span) => console.log('[start]', span.kind, span.name),
onSpanEnd: (span) => console.log('[end]', span.name, span.status?.code ?? 'ok', span.attributes),
};

await AgentExecutor.execute({ agent, input, provider, toolRegistry, exporter });
```

For real OpenTelemetry spans, import `createOtelTraceExporter()` from
`@loushy/build-ai-agent/otel` (needs the optional peer dependency
`@opentelemetry/api` and a registered `TracerProvider`). It carries span kind
and error status over to OpenTelemetry. See `examples/tracing`
(`npm run example:tracing:console`, `npm run example:tracing:otel`).

## Span tree

```
invoke_agent {agent name} INTERNAL
chat {model} CLIENT one per provider.generate() call
execute_tool {tool name} INTERNAL one per tool call
```

A flow run (`FlowExecutor.execute`) produces:

```
invoke_workflow {flow name} INTERNAL
flow.node {node type} INTERNAL one per node execution, nested like the flow
chat {model} CLIENT inside an llmCall node
execute_tool {tool name} INTERNAL inside a toolCall node
```

To nest a flow under an agent span, pass that span's id as `parentSpanId`
(`withSpan` hands the span to its callback); the flow span then becomes its
child.

## Attributes

Constants for every name live in `src/execution/semconv.ts` (exported as
`GenAiAttr`, `GenAiOperation`, `ErrorAttr`, `FlowAttr`, `LegacyAttr`).

### Agent run: `invoke_agent {agent name}`

| Attribute | Value |
| --- | --- |
| `gen_ai.operation.name` | `invoke_agent` |
| `gen_ai.agent.name` | The agent's `name` |
| `gen_ai.agent.id` | The agent's `id`, when set |
| `gen_ai.provider.name` | The provider's `name` |
| `gen_ai.conversation.id` | `sessionId`, when set |

### Model call: `chat {model}` (CLIENT)

| Attribute | Value |
| --- | --- |
| `gen_ai.operation.name` | `chat` |
| `gen_ai.provider.name` | The provider's `name` |
| `gen_ai.request.model` | The requested model |
| `gen_ai.request.temperature`, `gen_ai.request.max_tokens` | When set |
| `gen_ai.response.model` | When the provider reports it |
| `gen_ai.response.finish_reasons` | e.g. `["stop"]`, `["tool_call"]` |
| `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens` | Token usage |

### Tool call: `execute_tool {tool}`

| Attribute | Value |
| --- | --- |
| `gen_ai.operation.name` | `execute_tool` |
| `gen_ai.tool.name` | The tool name |
| `gen_ai.tool.call.id` | The model's tool-call id (agent runs) |
| `gen_ai.tool.description` | The tool's description, when it has one |
| `gen_ai.tool.type` | `function` |
| `gen_ai.agent.name`, `gen_ai.conversation.id` | The calling agent / session |

### Flows

The GenAI spec defines no convention for flow nodes, so node spans use the
`loushy.flow.*` namespace. The run span uses the spec's workflow convention.

| Span | Attribute | Value |
| --- | --- | --- |
| `invoke_workflow {name}` | `gen_ai.operation.name` | `invoke_workflow` |
| | `gen_ai.workflow.name` | The flow's `name` |
| | `loushy.flow.code` | The flow's `code` |
| | `loushy.flow.outcome` | `success` or `error` |
| `flow.node {type}` | `loushy.flow.node.id` | The node's id |
| | `loushy.flow.node.type` | The node type (`sequence`, `llmCall`, ...) |
| | `loushy.flow.outcome` | `success` or `error` |

### Errors

A failed span gets `error.type` and an error span status (OpenTelemetry
`ERROR`, message = the error message). `error.type` is the thrown error's
`name` (`_OTHER` when a non-`Error` is thrown). A tool call that returns an
error to the model (`isError`) is marked `error.type = tool_error`.

## Message and argument content is opt-in

Prompts, model output and tool arguments/results are sensitive and large, so
the `gen_ai.*` content attributes are **never recorded by default**. Opt in
per run:

```ts
import { AgentExecutor, type TraceExporter } from '@loushy/build-ai-agent';

declare const exporter: TraceExporter;

await AgentExecutor.execute({ agent, input, provider, exporter, captureContent: true });
```

Flows take the same flag on their context (`captureContent`). When
`captureContent` is not set, the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`
environment variable (the name the spec uses as its example) turns it on.
An explicit `captureContent: false` always wins.

With it on, content is recorded as JSON strings (the spec's fallback for
spans) in the spec's message schema:

| Attribute | On | Content |
| --- | --- | --- |
| `gen_ai.input.messages` | `invoke_agent`, `chat` | `[{ role, parts: [{ type: 'text', content }, { type: 'tool_call', id, name, arguments }, { type: 'tool_call_response', id, response }] }]` |
| `gen_ai.system_instructions` | `chat` | `[{ type: 'text', content }]` |
| `gen_ai.output.messages` | `chat` | `[{ role: 'assistant', parts: [...] }]` |
| `gen_ai.tool.call.arguments` | `execute_tool` | The arguments as JSON |
| `gen_ai.tool.call.result` | `execute_tool` | The result as JSON (not set when the tool failed) |

## Deprecated attribute names

Before the GenAI conventions, spans used ad-hoc names and the span names
`agent.run`, `llm.generate` and `tool.call`. The old attribute names are still
emitted next to the new ones so existing dashboards keep working, but they are
**deprecated** and will be removed in a future major version. Span names
changed (to what the spec requires); update queries that matched on them.

| Deprecated | Use instead |
| --- | --- |
| span `agent.run` | span `invoke_agent {name}`, `gen_ai.operation.name = invoke_agent` |
| span `llm.generate` | span `chat {model}`, `gen_ai.operation.name = chat` |
| span `tool.call` | span `execute_tool {tool}`, `gen_ai.operation.name = execute_tool` |
| `model` | `gen_ai.request.model` |
| `promptTokens` | `gen_ai.usage.input_tokens` |
| `completionTokens` | `gen_ai.usage.output_tokens` |
| `totalTokens` | (sum the two usage attributes) |
| `finishReason` | `gen_ai.response.finish_reasons` (an array; `tool_calls` is now `tool_call`) |
| `toolName` | `gen_ai.tool.name` |
| `error` (message on a thrown error; boolean on `execute_tool`) | `error.type` and the span status |
| `input`, `prompt`, `args`, `result` | `gen_ai.input.messages`, `gen_ai.tool.call.arguments`, `gen_ai.tool.call.result` (opt-in, see above) |

The deprecated content attributes (`input`, `prompt`, `args`, `result`) keep
their old behavior: they are recorded unless `redactContent: true`. Set
`redactContent: true` if you want no content on spans unless you opt in with
`captureContent`.

## Backends

Any backend that ingests OpenTelemetry traces (OTLP) receives these spans.
Backends and tools that understand the GenAI semantic conventions can render
them as LLM, agent and tool calls with token usage; others show them as
ordinary spans with attributes. Which conventions a given backend supports,
and which version of them, varies; check its documentation.
2 changes: 1 addition & 1 deletion examples/tracing/console-exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import { Span, TraceExporter } from '../../src/execution/tracing';

export function createConsoleExporter(): TraceExporter {
// Tracks depth so nested spans (llm.generate/tool.call under agent.run)
// Tracks depth so nested spans (chat/execute_tool under invoke_agent)
// print indented, purely as a readability aid.
const depthById = new Map<string, number>();

Expand Down
Loading
Loading