Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7548f26
changelog: cut Unreleased into 1.0.0-alpha.28
LinuxDevil Oct 21, 2026
281126e
feat(observe): local observability data plane (spec 0001)
LinuxDevil Oct 23, 2026
eccafa7
fix(observe): keep reporter identity worker-bundle-safe
LinuxDevil Oct 23, 2026
16edb96
feat(studio): observability panels in agent-forge (spec 0001)
LinuxDevil Oct 23, 2026
d07dd68
feat(observe): fleet monitoring sweep + alert hooks
LinuxDevil Oct 23, 2026
18ad996
feat(evals): emit score rows to the observe daemon
LinuxDevil Oct 23, 2026
f718506
feat(observe): prompt registry + context hub (spec 0001)
LinuxDevil Oct 24, 2026
78b6a17
feat(sandbox): sandbox() driver API + isolate driver (spec 0001)
LinuxDevil Oct 24, 2026
411b87b
fix(build): worker-shim isolated-vm in evalModule too
LinuxDevil Oct 24, 2026
59463a0
docs: observe daemon, prompt registry, sandbox in cli/observability d…
LinuxDevil Oct 24, 2026
07e2578
docs(api): doc comments for observe/sandbox exports; ratchet to 1758
LinuxDevil Oct 24, 2026
998efe1
chore(observe): satisfy the fallow gate + cli/observe coverage
LinuxDevil Oct 24, 2026
62942e2
docs(api): regenerate reports for the new observe/sandbox doc comments
LinuxDevil Oct 24, 2026
d1b226b
fix(observe): zod-4-safe 2-arg z.record in the ingest envelope
LinuxDevil Oct 24, 2026
098f2fd
chore(observe): maxCrap 40 on identity.ts (57% covered, platform-vary…
LinuxDevil Oct 24, 2026
95f2f83
fix(observe): annotate promptUseData so its emitted declaration is zo…
LinuxDevil Oct 24, 2026
e1549bf
chore(observe): api report for PromptUseData + size budgets for the n…
LinuxDevil Oct 24, 2026
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
59 changes: 57 additions & 2 deletions .fallowrc.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "./node_modules/fallow/schema.json",
"entry": [
"src/cli/{dev,chat,build,studio,mcp,doctor,init,eval}.ts",
"src/cli/{dev,chat,build,studio,mcp,doctor,init,eval,observe,prompt}.ts",
"**/*.test-d.ts",
"**/*.{test,spec}.{ts,tsx}",
"**/*.eval.ts",
Expand All @@ -20,6 +20,7 @@
"src/deploy/shims/google.worker.ts",
"src/deploy/shims/mcp.worker.ts",
"src/deploy/shims/pi.worker.ts",
"src/deploy/shims/isolatedVm.worker.ts",
"src/agentDir/__fixtures__/**/*.{ts,js}",
"examples/agent-dir/tools/*.ts",
"registry/**/*.ts"
Expand Down Expand Up @@ -94,7 +95,8 @@
"ignore": [
"**/*.test-d.ts",
"registry/**",
"examples/**"
"examples/**",
"apps/agent-forge/src/observe/types.ts"
]
},
"health": {
Expand Down Expand Up @@ -183,6 +185,59 @@
],
"maxCrap": 80,
"reason": "Low-complexity helpers whose per-function CRAP is inflated because fallow's coverage matching scores parts of them 0%-covered despite tests exercising them through higher-level calls (eval reporting, dev event formatting, the warn-once path). Ceiling exempts only the coverage term; cyclomatic/cognitive/unit-size still gate."
},
{
"files": [
"src/observe/reporter.ts"
],
"maxCyclomatic": 35,
"maxCognitive": 30,
"maxCrap": 35,
"reason": "observationOf is a flat attribute-mapping ladder (~15 optional gen_ai.* fields, one conditional each) and observeExporter a reporter factory; both are covered by reporter.test.ts (at 100% the CRAP floor equals cyclomatic). Splitting the ladder would scatter one schema across helpers without removing a decision. Unit-size still gates."
},
{
"files": [
"src/observe/sweep.ts"
],
"maxCyclomatic": 25,
"maxCognitive": 32,
"maxCrap": 140,
"reason": "sweepAgents' healthy/stale/down/interrupt status ladder is exercised by sweep.test.ts, but fallow scores its nested row callbacks at the uncovered estimate. Ceilings exempt the status-derivation density and the coverage term; unit-size still gates."
},
{
"files": [
"src/observe/store.ts"
],
"maxCognitive": 20,
"maxCrap": 220,
"reason": "The row mappers in listAgents/insightsAgents and the upsert/rollup SQL in writeObservation/observationRow run under store.test.ts and the route tests, but fallow's per-function matching scores nested arrows 0%-covered. Ceilings exempt the coverage term and the dense SQL assembly; cyclomatic and unit-size still gate."
},
{
"files": [
"src/observe/identity.ts"
],
"maxCognitive": 28,
"maxCrap": 40,
"reason": "resolveIdentity's hostname/pid/git/persisted-uuid ladder is covered through the reporter tests (~57% - the fs/git probe failure rungs vary by platform); each rung is one guarded probe. Ceilings exempt the probe density and the coverage term; cyclomatic and unit-size still gate."
},
{
"files": [
"src/observe/emit.ts",
"src/observe/daemon.ts",
"src/observe/routes/hub.ts",
"src/observe/routes/prompts.ts"
],
"maxCrap": 100,
"reason": "Covered by the emit/daemon/hub/prompts test files; the flagged handlers and nested callbacks fall below fallow's function-level coverage matching so it scores them at the uncovered estimate. Ceiling exempts only the coverage term; cyclomatic/cognitive/unit-size still gate."
},
{
"files": [
"src/cli/observe.ts",
"src/cli/prompt.ts"
],
"maxCognitive": 28,
"maxCrap": 60,
"reason": "CLI glue: observe.test.ts covers status/stop/token/rebuild/collision and prompt.test.ts covers push/list/get/env-port, but runStart's post-listen announce lines can't run in-process (a started daemon has no closeable handle and keeps the temp db locked). Ceiling exempts the arg-handling density and the coverage term; cyclomatic/unit-size still gate."
}
]
}
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,6 @@ audit/
tmp-*/
npmci-*.log
snip.txt

# Smoke-test artifacts
.smoke/shots/
45 changes: 45 additions & 0 deletions .smoke/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Live smoke test for the observability platform: prompt registry fetch +
* prompt_use, tool-call observation, hub publish/query, all reported to the
* daemon via LOUSHO_OBSERVE=1. Run: npx tsx .smoke/agent.ts
*/
import { createAgent, defineTool, hub } from '../src/index';
import { z } from 'zod';

const getTime = defineTool({
name: 'get_time',
description: 'Get the current date and time in ISO format.',
input: z.object({}),
execute: async () => new Date().toISOString(),
});

async function main() {
const h = hub();
if (!h) throw new Error('hub() is undefined - LOUSHO_OBSERVE not set?');
await h.publish({ namespace: 'smoke', key: 'last-run', value: new Date().toISOString(), scope: 'fleet' });
const entries = await h.query({ namespace: 'smoke', scope: 'fleet' });
console.log('[hub] fleet entries in smoke namespace:', entries.length);

const agent = createAgent({
name: 'smoke-agent',
model: 'openrouter/nvidia/nemotron-3-ultra-550b-a55b:free',
prompt: 'observe-test@latest',
tools: { get_time: getTime },
});

const first = await agent.send('Hey! What time is it right now?');
console.log('[agent 1]', first.text);
const second = await agent.send('And what day of the week is that?');
console.log('[agent 2]', second.text);

// Give the reporter's interval flush a moment, then close for the exit flush.
const reporter = (agent as unknown as { __observeReporter?: { close(): Promise<void> } }).__observeReporter;
await reporter?.close();
await new Promise((r) => setTimeout(r, 3000));
console.log('[smoke] done');
}

main().catch((e) => {
console.error('[smoke] failed:', e);
process.exit(1);
});
71 changes: 71 additions & 0 deletions .smoke/ui-check.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Drive the built Studio Observe UI end to end against the live daemon.
* Run from apps/agent-forge: npx tsx .smoke-ui.mts
*/
import { chromium } from 'playwright';

const BASE = 'http://127.0.0.1:4752/?token=vL_69opXZD_HsceHt608DJaQDxWrDzcy';
const shots = 'E:/agent-sdk/.smoke/shots';
const results: string[] = [];
async function check(name: string, fn: () => Promise<boolean>) {
try {
results.push(`${(await fn()) ? 'PASS' : 'FAIL'} ${name}`);
} catch (e) {
results.push(`FAIL ${name} (${e instanceof Error ? e.message : e})`);
}
}
const text = (page: import('playwright').Page, sel: string) => page.locator(sel).first().textContent({ timeout: 5000 }).catch(() => null);

const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });

await page.goto(BASE, { waitUntil: 'networkidle' });
await check('studio loads', async () => (await page.title()) !== '');

// Flip to the Observe surface.
await page.getByRole('button', { name: 'Observe' }).first().click();
await page.waitForSelector('section.observe', { timeout: 10_000 });
await check('observe tabs render', async () => (await page.getByRole('tab').count()) === 4);

// --- Traces tab ---
await page.waitForTimeout(1500);
await check('traces list has smoke-agent rows', async () => (await page.getByText('invoke_agent smoke-agent').count()) >= 2);
await page.screenshot({ path: `${shots}/01-traces.png`, fullPage: true });

// Open the first trace -> waterfall detail.
await page.getByText('invoke_agent smoke-agent').first().click();
await page.waitForTimeout(1200);
await check('trace detail shows generation obs', async () => (await text(page, 'body'))?.includes('chat nvidia') ?? false);
await check('trace detail shows tool call', async () => (await text(page, 'body'))?.includes('get_time') ?? false);
await page.screenshot({ path: `${shots}/02-trace-detail.png`, fullPage: true });
await page.getByRole('button', { name: /back|traces/i }).first().click().catch(() => page.keyboard.press('Escape'));
await page.waitForTimeout(600);

// --- Insights tab ---
await page.getByRole('tab', { name: 'Insights' }).click();
await page.waitForTimeout(1200);
await check('insights shows agent card', async () => (await text(page, 'body'))?.includes('smoke-agent') ?? false);
await check('insights shows latency figure', async () => /p50|p95|3825|4135/i.test((await text(page, 'body')) ?? ''));
await page.screenshot({ path: `${shots}/03-insights.png`, fullPage: true });

// --- Monitoring tab ---
await page.getByRole('tab', { name: 'Monitoring' }).click();
await page.waitForTimeout(1200);
await check('fleet table has smoke-agent', async () => (await text(page, 'body'))?.includes('smoke-agent') ?? false);
await check('fleet table has status', async () => /stopped|healthy|stale|down/i.test((await text(page, 'body')) ?? ''));
await page.screenshot({ path: `${shots}/04-monitoring.png`, fullPage: true });

// --- Scores tab ---
await page.getByRole('tab', { name: 'Scores' }).click();
await page.waitForTimeout(1200);
await check('scores shows eval row', async () => (await text(page, 'body'))?.includes('smoke: eval') ?? false);
await page.screenshot({ path: `${shots}/05-scores.png`, fullPage: true });

await check('no page errors', async () => errors.length === 0);

console.log(results.join('\n'));
if (errors.length) console.log('\npage errors:\n' + errors.slice(0, 5).join('\n'));
await browser.close();
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ What is on `main` and not yet on npm. Entries marked **Behavior change** can
break working code; [Upgrading](docs/upgrading.md) lists each one with its
opt-out.

### Added

- The `lousho observe` daemon: a local collector (loopback, `http://127.0.0.1:7777`, SQLite at `~/.lousho/observe.db`) that gathers every agent's traces, heartbeats and eval scores when `LOUSHO_OBSERVE` is set — no per-agent code changes. `lousho observe start|status|stop|rebuild-rollups|token create`; a second `start` on a busy port attaches when it is already the observe daemon. See [Observability](docs/observability.md#the-observe-daemon).
- Agent Forge gains Observe tabs — Traces, Insights, Monitoring, Scores — reading the daemon through a token-gated `/observe/**` proxy on the studio server.
- Prompt registry: `createAgent({ prompt: 'name@version' })` or `LOUSHO_PROMPT` resolves versioned instructions from the daemon at build (embedded `instructions` are the fallback); every run records the version used. `lousho prompt push|list|get` manages it.
- Context hub: `hub()` / `hubTools()` publish and query namespaced shared memory scoped `private` (the writing agent) or `fleet` (every agent on the daemon).
- Fleet monitoring: heartbeat-derived agent status, `agent.deregister` on graceful exit, a crashed agent's open sessions and unfinished observations flip to `interrupted`, and webhook alerts (`--webhook`, `alerts.json`) on down transitions and error-rate thresholds.
- Eval scores report to the daemon: every `AssertionResult` lands as a score row on the exercised run's trace (`source: eval` or `judge`); `lousho eval --observe` forces emission.
- `sandbox({ driver: 'isolate' })` runs tool code in an `isolated-vm` V8 isolate (optional peer `isolated-vm`); `defineTool({ sandbox })` opts a tool in, timeouts and OOM map to `sandbox.error` on the tool span. `'docker'` and `'e2b'` driver names are reserved for later drivers.

## [1.0.0-alpha.29] - 2026-10-21

### Changed

- **Behavior change:** the built node-server and Docker image refuse to start on a non-loopback host when no auth is configured (no `LOUSHO_API_TOKEN`, build token or `auth.ts`), with `LOUSHO_SERVER_UNPROTECTED`. Set a token, add `auth.ts`, bind to `127.0.0.1`, or set `LOUSHO_ALLOW_PUBLIC=1` to serve an open agent on purpose. `lousho build --target=docker` prints the run command with `-e LOUSHO_API_TOKEN=<token>`.
Expand Down
27 changes: 25 additions & 2 deletions api/executor.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2031,6 +2031,23 @@ interface SandboxAdapter {
writeFile(path: string, content: string): Promise<void>;
}

// @public
interface SandboxCallOptions {
args?: unknown[];
}

// @public
interface SandboxDriver {
readonly name?: string;
run(code: string, opts?: SandboxCallOptions): Promise<unknown>;
}

// @public
type SandboxDriverName = 'isolate' | 'docker' | 'e2b';

// @public
type SandboxDriverRef = SandboxDriverName | SandboxDriver;

// @public
interface SandboxResult {
// (undocumented)
Expand Down Expand Up @@ -2625,6 +2642,7 @@ interface ToolDescriptor {
// (undocumented)
needsApproval?: boolean | ApprovalPredicate;
requiresSandbox?: boolean;
sandbox?: ToolSandboxOptions;
sandboxExecute?: (args: unknown, sandbox: SandboxAdapter, ctx?: ToolExecutionContext) => Promise<unknown>;
tool: Tool;
transient?: boolean;
Expand Down Expand Up @@ -2736,6 +2754,13 @@ interface ToolResumeEvent extends AgentEventBase<'tool.resume'> {
toolName: string;
}

// @public
interface ToolSandboxOptions {
driver?: SandboxDriverRef;
memoryMb?: number;
timeoutMs?: number;
}

// @public
interface ToolSearchOptions {
contextWindow?: number;
Expand Down Expand Up @@ -2764,9 +2789,7 @@ interface ToolStartEvent extends AgentEventBase<'tool.start'> {

// @public
interface TraceExporter {
// (undocumented)
onSpanEnd(span: Span): void;
// (undocumented)
onSpanStart(span: Span): void;
}

Expand Down
27 changes: 25 additions & 2 deletions api/flows.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,23 @@ interface SandboxAdapter {
writeFile(path: string, content: string): Promise<void>;
}

// @public
interface SandboxCallOptions {
args?: unknown[];
}

// @public
interface SandboxDriver {
readonly name?: string;
run(code: string, opts?: SandboxCallOptions): Promise<unknown>;
}

// @public
type SandboxDriverName = 'isolate' | 'docker' | 'e2b';

// @public
type SandboxDriverRef = SandboxDriverName | SandboxDriver;

// @public
interface SandboxResult {
// (undocumented)
Expand Down Expand Up @@ -1407,6 +1424,7 @@ interface ToolDescriptor {
// (undocumented)
needsApproval?: boolean | ApprovalPredicate;
requiresSandbox?: boolean;
sandbox?: ToolSandboxOptions;
sandboxExecute?: (args: unknown, sandbox: SandboxAdapter, ctx?: ToolExecutionContext) => Promise<unknown>;
tool: Tool;
transient?: boolean;
Expand Down Expand Up @@ -1465,14 +1483,19 @@ class ToolRegistry {
unregister(name: string): boolean;
}

// @public
interface ToolSandboxOptions {
driver?: SandboxDriverRef;
memoryMb?: number;
timeoutMs?: number;
}

// @public
type ToolsOption = ReadonlyArray<ToolArrayEntry> | Record<string, ToolDescriptor | HostedTool>;

// @public
interface TraceExporter {
// (undocumented)
onSpanEnd(span: Span): void;
// (undocumented)
onSpanStart(span: Span): void;
}

Expand Down
Loading
Loading