From 773376889bf123062ed887715ab75b3de06b37d8 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sat, 24 Oct 2026 16:55:14 +0300 Subject: [PATCH 1/4] feat(observe): capture message content by default on a local collector Co-Authored-By: Claude Opus 5.5 --- docs/observability.md | 8 ++++++ llms-full.txt | 8 ++++++ src/createAgent.ts | 12 ++++++--- src/createAgentTracing.test.ts | 48 +++++++++++++++++++++++++++++++++- src/observe/emit.test.ts | 11 +++++++- src/observe/emit.ts | 14 ++++++++++ 6 files changed, 96 insertions(+), 5 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index 84a80f21..0c0d812d 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -236,6 +236,14 @@ 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 agent with `createAgent({ exporter, captureContent: true })`. +The exception is a local collector: when `LOUSHO_OBSERVE` points at loopback +(`1`/`true`, or a URL whose host is `127.0.0.1`, `localhost` or `::1`), +`createAgent` defaults `captureContent` to `true`, so the daemon's trace view +shows the prompt, the model output and the tool arguments and results that +explain a failure. The data stays in the daemon's db on your machine. Turn it +off with `captureContent: false`; a collector beyond loopback keeps the +opt-in default, and `redactContent` still applies. + #### Advanced: the executor API With the executor, opt in per run: diff --git a/llms-full.txt b/llms-full.txt index 2a367a48..add3e8a1 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -13896,6 +13896,14 @@ 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 agent with `createAgent({ exporter, captureContent: true })`. +The exception is a local collector: when `LOUSHO_OBSERVE` points at loopback +(`1`/`true`, or a URL whose host is `127.0.0.1`, `localhost` or `::1`), +`createAgent` defaults `captureContent` to `true`, so the daemon's trace view +shows the prompt, the model output and the tool arguments and results that +explain a failure. The data stays in the daemon's db on your machine. Turn it +off with `captureContent: false`; a collector beyond loopback keeps the +opt-in default, and `redactContent` still applies. + #### Advanced: the executor API With the executor, opt in per run: diff --git a/src/createAgent.ts b/src/createAgent.ts index 163f9c5a..dffd0f09 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -20,6 +20,7 @@ import { AgentExecutor, ExecuteOptions, ExecutionResult } from './execution/Agen import { streamResumed, throwingRun, type AgentRun } from './execution/agentRun'; import type { AgentEvent } from './execution/agentEvents'; import type { TraceExporter } from './execution/tracing'; +import { isLoopbackTarget, observeTargetFromEnv } from './observe/emit'; import { withObserveExporter, type PromptUseProvider } from './observe/reporter'; import { parsePromptRef, registryPrompt } from './observe/promptClient'; import { assertModelSettings, mergeModelSettings } from './execution/modelSettings'; @@ -409,8 +410,9 @@ export interface CreateAgentBase agentName, resolvedInstructions.promptUse !== undefined ? { promptUse: resolvedInstructions.promptUse } : {} ); + // Content defaults on when the env collector is this machine's own (the trace + // view cannot explain a failure without it); an explicit setting always wins. + const observeTarget = observeTargetFromEnv(); + const captureContent = config.captureContent ?? (observeTarget && isLoopbackTarget(observeTarget) ? true : undefined); const tracing: Pick = { ...(exporter && { exporter }), - ...(config.captureContent !== undefined && { captureContent: config.captureContent }), + ...(captureContent !== undefined && { captureContent }), ...(config.redactContent !== undefined && { redactContent: config.redactContent }), }; const checkpoints = config.store?.checkpoints; diff --git a/src/createAgentTracing.test.ts b/src/createAgentTracing.test.ts index ff84001b..6cbbb4ef 100644 --- a/src/createAgentTracing.test.ts +++ b/src/createAgentTracing.test.ts @@ -6,7 +6,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import type { Tracer } from '@opentelemetry/api'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; import { createAgent } from './createAgent'; import { PropagatingToolError } from './execution/AgentExecutor'; @@ -331,3 +331,49 @@ describe('createAgent({ exporter })', () => { }); }); }); + +describe('captureContent default on a local collector', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + const observe = (value: string) => { + vi.stubEnv('LOUSHO_OBSERVE', value); + vi.stubGlobal('fetch', async () => new Response('{}', { status: 200 })); + }; + const chatInput = async (options: { captureContent?: boolean; redactContent?: boolean } = {}) => { + const exporter = recording(); + await createAgent({ provider: mockModel(['Hi.']), exporter, ...options }).send('my key is sk-secret123'); + return exporter.ended.find((span) => op(span) === 'chat')!; + }; + + it('captures content by default when LOUSHO_OBSERVE points at loopback', async () => { + observe('1'); + const chat = await chatInput(); + expect(chat.attributes['gen_ai.input.messages']).toContain('my key is'); + expect(chat.attributes['gen_ai.output.messages']).toContain('Hi.'); + }); + + it('does not capture by default for a non-loopback collector', async () => { + observe('http://collector.test:7777'); + expect((await chatInput()).attributes['gen_ai.input.messages']).toBeUndefined(); + }); + + it('does not capture by default when LOUSHO_OBSERVE is unset', async () => { + vi.stubEnv('LOUSHO_OBSERVE', ''); + expect((await chatInput()).attributes['gen_ai.input.messages']).toBeUndefined(); + }); + + it('captureContent: false wins over the loopback default', async () => { + observe('1'); + expect((await chatInput({ captureContent: false })).attributes['gen_ai.input.messages']).toBeUndefined(); + }); + + it('redactContent still applies to loopback-captured content', async () => { + observe('1'); + const chat = await chatInput({ redactContent: true }); + // The gen_ai.* attributes follow captureContent; the deprecated ones stay redacted. + expect(chat.attributes['gen_ai.input.messages']).toBeDefined(); + expect(chat.attributes.prompt).toBeUndefined(); + }); +}); diff --git a/src/observe/emit.test.ts b/src/observe/emit.test.ts index 1e205d7d..89912052 100644 --- a/src/observe/emit.test.ts +++ b/src/observe/emit.test.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest'; import type { IngestEvent } from './envelope'; -import { emitObserveEvent, emitObserveEvents, observeTargetFromEnv, resetObserveEnvWarning } from './emit'; +import { emitObserveEvent, emitObserveEvents, isLoopbackTarget, observeTargetFromEnv, resetObserveEnvWarning } from './emit'; const score = (id: string): IngestEvent => ({ type: 'score', @@ -112,3 +112,12 @@ describe('emitObserveEvents', () => { } }); }); + +describe('isLoopbackTarget', () => { + it.each(['http://127.0.0.1:7777', 'http://localhost:1234', 'http://[::1]:7777', 'https://LOCALHOST'])('%s is loopback', (url) => { + expect(isLoopbackTarget({ url })).toBe(true); + }); + it.each(['http://collector.test:7777', 'https://observe.example.com', 'http://0.0.0.0:7777', 'http://localhost.evil.com', 'not a url'])('%s is not', (url) => { + expect(isLoopbackTarget({ url })).toBe(false); + }); +}); diff --git a/src/observe/emit.ts b/src/observe/emit.ts index 3bc3601e..184db5af 100644 --- a/src/observe/emit.ts +++ b/src/observe/emit.ts @@ -76,6 +76,20 @@ export function observeTargetFromEnv(env: NodeJS.ProcessEnv | undefined = typeof }; } +/** + * Whether `target` is this machine's own collector (host `127.0.0.1`, + * `localhost` or `::1`). A loopback collector is auth-free and its db never + * leaves the machine, so content capture defaults on for it. + */ +export function isLoopbackTarget(target: ObserveTarget): boolean { + try { + const host = new URL(target.url).hostname.toLowerCase(); + return host === '127.0.0.1' || host === 'localhost' || host === '[::1]'; + } catch { + return false; + } +} + export interface EmitObserveOptions { /** Explicit target; default the env's (`observeTargetFromEnv`). */ target?: ObserveTarget; From 7d75fe82043d5a1e18d9f481023b280bd4f542d6 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sat, 24 Oct 2026 16:59:07 +0300 Subject: [PATCH 2/4] test(observe): make the loopback redaction test falsifiable; clarify redactContent docs Co-Authored-By: Claude Opus 5.5 --- docs/observability.md | 7 ++++++- llms-full.txt | 7 ++++++- src/createAgentTracing.test.ts | 35 +++++++++++++++++++++++++++++++--- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index 0c0d812d..3e841177 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -242,7 +242,12 @@ The exception is a local collector: when `LOUSHO_OBSERVE` points at loopback shows the prompt, the model output and the tool arguments and results that explain a failure. The data stays in the daemon's db on your machine. Turn it off with `captureContent: false`; a collector beyond loopback keeps the -opt-in default, and `redactContent` still applies. +opt-in default. The default applies to agents built with `createAgent`. + +`redactContent` keeps only the legacy `input`/`prompt`/`args`/`result` +attributes off; it does not scrub `gen_ai.*` message content. On a loopback +collector the message text, including any secret typed into a prompt, is +stored in the local db, so set `captureContent: false` if that is not wanted. #### Advanced: the executor API diff --git a/llms-full.txt b/llms-full.txt index add3e8a1..ab7533c2 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -13902,7 +13902,12 @@ The exception is a local collector: when `LOUSHO_OBSERVE` points at loopback shows the prompt, the model output and the tool arguments and results that explain a failure. The data stays in the daemon's db on your machine. Turn it off with `captureContent: false`; a collector beyond loopback keeps the -opt-in default, and `redactContent` still applies. +opt-in default. The default applies to agents built with `createAgent`. + +`redactContent` keeps only the legacy `input`/`prompt`/`args`/`result` +attributes off; it does not scrub `gen_ai.*` message content. On a loopback +collector the message text, including any secret typed into a prompt, is +stored in the local db, so set `captureContent: false` if that is not wanted. #### Advanced: the executor API diff --git a/src/createAgentTracing.test.ts b/src/createAgentTracing.test.ts index 6cbbb4ef..179a3317 100644 --- a/src/createAgentTracing.test.ts +++ b/src/createAgentTracing.test.ts @@ -369,11 +369,40 @@ describe('captureContent default on a local collector', () => { expect((await chatInput({ captureContent: false })).attributes['gen_ai.input.messages']).toBeUndefined(); }); - it('redactContent still applies to loopback-captured content', async () => { + it('redactContent: false records the deprecated prompt next to the loopback-captured content', async () => { + observe('1'); + const chat = await chatInput({ redactContent: false }); + expect(chat.attributes['gen_ai.input.messages']).toContain('my key is'); + expect(chat.attributes.prompt).toBeDefined(); + }); + + it('redactContent: true keeps the deprecated prompt off while gen_ai content is still captured', async () => { observe('1'); const chat = await chatInput({ redactContent: true }); - // The gen_ai.* attributes follow captureContent; the deprecated ones stay redacted. - expect(chat.attributes['gen_ai.input.messages']).toBeDefined(); + expect(chat.attributes['gen_ai.input.messages']).toContain('my key is'); expect(chat.attributes.prompt).toBeUndefined(); }); + + it('resume() captures content by default on loopback', async () => { + observe('1'); + const store = memoryStore(); + let crashed = false; + const flaky = defineTool({ + name: 'flaky', + description: 'Dies once', + input: z.object({}), + execute: async () => { + if (!crashed) { + crashed = true; + throw new PropagatingToolError('process died'); + } + return 'ok'; + }, + }); + const crashing = createAgent({ provider: mockModel([{ toolCalls: [{ name: 'flaky', id: 'c1' }] }]), tools: [flaky], store }); + await expect(crashing.send('Go', { sessionId: 'job' })).rejects.toThrow('process died'); + const exporter = recording(); + await createAgent({ provider: mockModel(['Done.']), tools: [flaky], store, exporter }).resume('job'); + expect(exporter.ended.find((span) => op(span) === 'chat')!.attributes['gen_ai.output.messages']).toContain('Done.'); + }); }); From d9f691aee5246df52a379a9520697f2ef86abe6a Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sat, 24 Oct 2026 17:06:30 +0300 Subject: [PATCH 3/4] fix(prompt): fail clearly when a registry prompt is unreachable and has no fallback createAgent({ prompt: 'name@latest' }) with no instructions and an unreachable daemon (or 404, or LOUSHO_OBSERVE unset) ran with no system prompt while the log claimed the embedded instructions were used. It now rejects the first run with SDKError LOUSHO_PROMPT_UNRESOLVED; with instructions set, behaviour and the warning text are unchanged. Co-Authored-By: Claude Opus 5.5 --- api/index.api.md | 1 + api/mcp.api.md | 1 + api/testing.api.md | 1 + api/tools.api.md | 1 + api/worker.api.md | 1 + docs/errors.md | 18 +++++++++++++++- docs/observability.md | 3 ++- llms-full.txt | 21 ++++++++++++++++-- src/cli/prompt.ts | 3 ++- src/createAgent.ts | 25 +++++++++++++++++++--- src/createAgentPrompt.test.ts | 40 ++++++++++++++++++++++++++++++++--- src/observe/promptClient.ts | 9 +++++--- src/utils/errorCodes.ts | 1 + 13 files changed, 111 insertions(+), 14 deletions(-) diff --git a/api/index.api.md b/api/index.api.md index 2dac032a..ee040098 100644 --- a/api/index.api.md +++ b/api/index.api.md @@ -1923,6 +1923,7 @@ export const ERROR_CODES: { readonly LOUSHO_REGISTRY_UNSAFE_PATH: "Do not install this item; it asks to write outside its allowed folder or is too large, so tell the registry owner."; readonly LOUSHO_REGISTRY_FILE_EXISTS: "Pass --overwrite to replace the existing file, or move your file away first."; readonly LOUSHO_REGISTRY_MANIFEST_MISMATCH: "Do not install this item: its code reaches for something its permission manifest does not declare; tell the registry owner, who fixes the code or the manifest."; + readonly LOUSHO_PROMPT_UNRESOLVED: "Start the observe daemon (set LOUSHO_OBSERVE to its URL), or pass `instructions` as a fallback."; readonly LOUSHO_TOOL_NOT_FOUND: "Use one of the tool names listed in the message, or register the tool yourself."; readonly LOUSHO_TOOL_NEEDS_CREDENTIALS: "Build the agent with createAgent() and pass the configured tool."; readonly LOUSHO_TOOL_EXECUTION_FAILED: "Look at the tool's own error (the `cause`) and fix the tool or its input."; diff --git a/api/mcp.api.md b/api/mcp.api.md index 1a44a29f..3ba8774d 100644 --- a/api/mcp.api.md +++ b/api/mcp.api.md @@ -560,6 +560,7 @@ const ERROR_CODES: { readonly LOUSHO_REGISTRY_UNSAFE_PATH: "Do not install this item; it asks to write outside its allowed folder or is too large, so tell the registry owner."; readonly LOUSHO_REGISTRY_FILE_EXISTS: "Pass --overwrite to replace the existing file, or move your file away first."; readonly LOUSHO_REGISTRY_MANIFEST_MISMATCH: "Do not install this item: its code reaches for something its permission manifest does not declare; tell the registry owner, who fixes the code or the manifest."; + readonly LOUSHO_PROMPT_UNRESOLVED: "Start the observe daemon (set LOUSHO_OBSERVE to its URL), or pass `instructions` as a fallback."; readonly LOUSHO_TOOL_NOT_FOUND: "Use one of the tool names listed in the message, or register the tool yourself."; readonly LOUSHO_TOOL_NEEDS_CREDENTIALS: "Build the agent with createAgent() and pass the configured tool."; readonly LOUSHO_TOOL_EXECUTION_FAILED: "Look at the tool's own error (the `cause`) and fix the tool or its input."; diff --git a/api/testing.api.md b/api/testing.api.md index 7b2d5b54..e566c00d 100644 --- a/api/testing.api.md +++ b/api/testing.api.md @@ -82,6 +82,7 @@ const ERROR_CODES: { readonly LOUSHO_REGISTRY_UNSAFE_PATH: "Do not install this item; it asks to write outside its allowed folder or is too large, so tell the registry owner."; readonly LOUSHO_REGISTRY_FILE_EXISTS: "Pass --overwrite to replace the existing file, or move your file away first."; readonly LOUSHO_REGISTRY_MANIFEST_MISMATCH: "Do not install this item: its code reaches for something its permission manifest does not declare; tell the registry owner, who fixes the code or the manifest."; + readonly LOUSHO_PROMPT_UNRESOLVED: "Start the observe daemon (set LOUSHO_OBSERVE to its URL), or pass `instructions` as a fallback."; readonly LOUSHO_TOOL_NOT_FOUND: "Use one of the tool names listed in the message, or register the tool yourself."; readonly LOUSHO_TOOL_NEEDS_CREDENTIALS: "Build the agent with createAgent() and pass the configured tool."; readonly LOUSHO_TOOL_EXECUTION_FAILED: "Look at the tool's own error (the `cause`) and fix the tool or its input."; diff --git a/api/tools.api.md b/api/tools.api.md index 305b4d72..1629c565 100644 --- a/api/tools.api.md +++ b/api/tools.api.md @@ -656,6 +656,7 @@ const ERROR_CODES: { readonly LOUSHO_REGISTRY_UNSAFE_PATH: "Do not install this item; it asks to write outside its allowed folder or is too large, so tell the registry owner."; readonly LOUSHO_REGISTRY_FILE_EXISTS: "Pass --overwrite to replace the existing file, or move your file away first."; readonly LOUSHO_REGISTRY_MANIFEST_MISMATCH: "Do not install this item: its code reaches for something its permission manifest does not declare; tell the registry owner, who fixes the code or the manifest."; + readonly LOUSHO_PROMPT_UNRESOLVED: "Start the observe daemon (set LOUSHO_OBSERVE to its URL), or pass `instructions` as a fallback."; readonly LOUSHO_TOOL_NOT_FOUND: "Use one of the tool names listed in the message, or register the tool yourself."; readonly LOUSHO_TOOL_NEEDS_CREDENTIALS: "Build the agent with createAgent() and pass the configured tool."; readonly LOUSHO_TOOL_EXECUTION_FAILED: "Look at the tool's own error (the `cause`) and fix the tool or its input."; diff --git a/api/worker.api.md b/api/worker.api.md index 63a4c3ef..51617f27 100644 --- a/api/worker.api.md +++ b/api/worker.api.md @@ -1297,6 +1297,7 @@ const ERROR_CODES: { readonly LOUSHO_REGISTRY_UNSAFE_PATH: "Do not install this item; it asks to write outside its allowed folder or is too large, so tell the registry owner."; readonly LOUSHO_REGISTRY_FILE_EXISTS: "Pass --overwrite to replace the existing file, or move your file away first."; readonly LOUSHO_REGISTRY_MANIFEST_MISMATCH: "Do not install this item: its code reaches for something its permission manifest does not declare; tell the registry owner, who fixes the code or the manifest."; + readonly LOUSHO_PROMPT_UNRESOLVED: "Start the observe daemon (set LOUSHO_OBSERVE to its URL), or pass `instructions` as a fallback."; readonly LOUSHO_TOOL_NOT_FOUND: "Use one of the tool names listed in the message, or register the tool yourself."; readonly LOUSHO_TOOL_NEEDS_CREDENTIALS: "Build the agent with createAgent() and pass the configured tool."; readonly LOUSHO_TOOL_EXECUTION_FAILED: "Look at the tool's own error (the `cause`) and fix the tool or its input."; diff --git a/docs/errors.md b/docs/errors.md index 4e7a4f15..acb14a45 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -88,7 +88,7 @@ Find a code by area: | Area | Codes | When you meet them | | ---- | ----- | ------------------ | -| [Configuration](#configuration) | [`LOUSHO_CONFIG_INVALID`](#lousho_config_invalid), [`LOUSHO_CONFIG_MISSING_PROVIDER`](#lousho_config_missing_provider), [`LOUSHO_CONFIG_MISSING_AGENT`](#lousho_config_missing_agent), [`LOUSHO_CONFIG_MISSING_INPUT`](#lousho_config_missing_input), [`LOUSHO_CONFIG_CONFLICTING_OPTIONS`](#lousho_config_conflicting_options), [`LOUSHO_CONFIG_MISSING_CHECKPOINT_STORE`](#lousho_config_missing_checkpoint_store), [`LOUSHO_CONFIG_RESOLVER_FAILED`](#lousho_config_resolver_failed) | A `createAgent()` or executor call that is missing or mixing options, or a config resolver that failed. | +| [Configuration](#configuration) | [`LOUSHO_CONFIG_INVALID`](#lousho_config_invalid), [`LOUSHO_CONFIG_MISSING_PROVIDER`](#lousho_config_missing_provider), [`LOUSHO_CONFIG_MISSING_AGENT`](#lousho_config_missing_agent), [`LOUSHO_CONFIG_MISSING_INPUT`](#lousho_config_missing_input), [`LOUSHO_CONFIG_CONFLICTING_OPTIONS`](#lousho_config_conflicting_options), [`LOUSHO_CONFIG_MISSING_CHECKPOINT_STORE`](#lousho_config_missing_checkpoint_store), [`LOUSHO_CONFIG_RESOLVER_FAILED`](#lousho_config_resolver_failed), [`LOUSHO_PROMPT_UNRESOLVED`](#lousho_prompt_unresolved) | A `createAgent()` or executor call that is missing or mixing options, or a config resolver that failed. | | [Providers and peers](#providers-and-peers) | [`LOUSHO_PROVIDER_SPEC_INVALID`](#lousho_provider_spec_invalid), [`LOUSHO_PROVIDER_UNKNOWN`](#lousho_provider_unknown), [`LOUSHO_PROVIDER_MISSING_API_KEY`](#lousho_provider_missing_api_key), [`LOUSHO_PROVIDER_REQUEST_FAILED`](#lousho_provider_request_failed), [`LOUSHO_PROVIDER_RATE_LIMITED`](#lousho_provider_rate_limited), [`LOUSHO_PEER_MISSING`](#lousho_peer_missing), [`LOUSHO_HOSTED_TOOL_UNSUPPORTED`](#lousho_hosted_tool_unsupported), [`LOUSHO_UNSUPPORTED_CONTENT`](#lousho_unsupported_content) | Resolving a model string, a missing API key or peer package, a failed or rate-limited model request, a hosted tool the provider cannot run, a file the provider cannot send. | | [Agent spec files](#agent-spec-files) | [`LOUSHO_SPEC_NOT_FOUND`](#lousho_spec_not_found), [`LOUSHO_SPEC_INVALID`](#lousho_spec_invalid), [`LOUSHO_SPEC_UNKNOWN_FIELD`](#lousho_spec_unknown_field), [`LOUSHO_SPEC_UNSUPPORTED_FORMAT`](#lousho_spec_unsupported_format) | Loading an `AgentSpec` file with `loadSpec()` or `specToAgent()`. | | [Tools](#tools) | [`LOUSHO_TOOL_NOT_FOUND`](#lousho_tool_not_found), [`LOUSHO_TOOL_NEEDS_CREDENTIALS`](#lousho_tool_needs_credentials), [`LOUSHO_TOOL_EXECUTION_FAILED`](#lousho_tool_execution_failed), [`LOUSHO_TOOL_ARGS_INVALID`](#lousho_tool_args_invalid), [`LOUSHO_MCP_START_FAILED`](#lousho_mcp_start_failed), [`LOUSHO_MCP_CALL_FAILED`](#lousho_mcp_call_failed) | A tool the model called is unknown, needs credentials, threw, or got invalid arguments; a stdio MCP server did not start or a call to one failed. | @@ -182,6 +182,22 @@ names the option and `error.cause` is what the function threw. **Example:** `createAgent({ provider, model: ({ metadata }) => plans[metadata.plan].model })` with an unknown plan. +### LOUSHO_PROMPT_UNRESOLVED + +**Means:** `createAgent({ prompt: 'name@version' })` (or `LOUSHO_PROMPT`) has no +`instructions` fallback and the prompt could not be fetched: the observe daemon +is unreachable, answered 404 for that name and version, or `LOUSHO_OBSERVE` is +not set. The first `send()` rejects and `stream()` ends with an `error` event; +the model is never called, so the agent never runs without a system prompt. The +message names the prompt ref. + +**Fix:** start the daemon (`lousho observe`) and set `LOUSHO_OBSERVE` to its URL, +publish the prompt (`lousho prompt push`), or pass `instructions` as the fallback +(with it set, an unreachable daemon only logs a warning and the run uses the +embedded text). See [Observability](./observability.md). + +**Example:** `createAgent({ provider, prompt: 'mira@latest' })` with no daemon running. + ## Providers and peers ### LOUSHO_PROVIDER_SPEC_INVALID diff --git a/docs/observability.md b/docs/observability.md index 3e841177..98fecbba 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -417,7 +417,8 @@ The daemon also serves two stores agents can use at runtime: - **Prompt registry.** `createAgent({ prompt: 'name@version' })` or `LOUSHO_PROMPT` fetches instructions from it at build (`@latest` resolves - on the daemon; an unreachable daemon falls back to `instructions`). Every + on the daemon; an unreachable daemon falls back to `instructions`; with no + `instructions`, the first run fails with `LOUSHO_PROMPT_UNRESOLVED`). Every run logs the exact version used. `lousho prompt push` publishes a new version. - **Context hub.** `hub()` / `hubTools()` let agents publish and query diff --git a/llms-full.txt b/llms-full.txt index ab7533c2..52f2c685 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -8391,7 +8391,7 @@ Find a code by area: | Area | Codes | When you meet them | | ---- | ----- | ------------------ | -| [Configuration](#configuration) | [`LOUSHO_CONFIG_INVALID`](#lousho_config_invalid), [`LOUSHO_CONFIG_MISSING_PROVIDER`](#lousho_config_missing_provider), [`LOUSHO_CONFIG_MISSING_AGENT`](#lousho_config_missing_agent), [`LOUSHO_CONFIG_MISSING_INPUT`](#lousho_config_missing_input), [`LOUSHO_CONFIG_CONFLICTING_OPTIONS`](#lousho_config_conflicting_options), [`LOUSHO_CONFIG_MISSING_CHECKPOINT_STORE`](#lousho_config_missing_checkpoint_store), [`LOUSHO_CONFIG_RESOLVER_FAILED`](#lousho_config_resolver_failed) | A `createAgent()` or executor call that is missing or mixing options, or a config resolver that failed. | +| [Configuration](#configuration) | [`LOUSHO_CONFIG_INVALID`](#lousho_config_invalid), [`LOUSHO_CONFIG_MISSING_PROVIDER`](#lousho_config_missing_provider), [`LOUSHO_CONFIG_MISSING_AGENT`](#lousho_config_missing_agent), [`LOUSHO_CONFIG_MISSING_INPUT`](#lousho_config_missing_input), [`LOUSHO_CONFIG_CONFLICTING_OPTIONS`](#lousho_config_conflicting_options), [`LOUSHO_CONFIG_MISSING_CHECKPOINT_STORE`](#lousho_config_missing_checkpoint_store), [`LOUSHO_CONFIG_RESOLVER_FAILED`](#lousho_config_resolver_failed), [`LOUSHO_PROMPT_UNRESOLVED`](#lousho_prompt_unresolved) | A `createAgent()` or executor call that is missing or mixing options, or a config resolver that failed. | | [Providers and peers](#providers-and-peers) | [`LOUSHO_PROVIDER_SPEC_INVALID`](#lousho_provider_spec_invalid), [`LOUSHO_PROVIDER_UNKNOWN`](#lousho_provider_unknown), [`LOUSHO_PROVIDER_MISSING_API_KEY`](#lousho_provider_missing_api_key), [`LOUSHO_PROVIDER_REQUEST_FAILED`](#lousho_provider_request_failed), [`LOUSHO_PROVIDER_RATE_LIMITED`](#lousho_provider_rate_limited), [`LOUSHO_PEER_MISSING`](#lousho_peer_missing), [`LOUSHO_HOSTED_TOOL_UNSUPPORTED`](#lousho_hosted_tool_unsupported), [`LOUSHO_UNSUPPORTED_CONTENT`](#lousho_unsupported_content) | Resolving a model string, a missing API key or peer package, a failed or rate-limited model request, a hosted tool the provider cannot run, a file the provider cannot send. | | [Agent spec files](#agent-spec-files) | [`LOUSHO_SPEC_NOT_FOUND`](#lousho_spec_not_found), [`LOUSHO_SPEC_INVALID`](#lousho_spec_invalid), [`LOUSHO_SPEC_UNKNOWN_FIELD`](#lousho_spec_unknown_field), [`LOUSHO_SPEC_UNSUPPORTED_FORMAT`](#lousho_spec_unsupported_format) | Loading an `AgentSpec` file with `loadSpec()` or `specToAgent()`. | | [Tools](#tools) | [`LOUSHO_TOOL_NOT_FOUND`](#lousho_tool_not_found), [`LOUSHO_TOOL_NEEDS_CREDENTIALS`](#lousho_tool_needs_credentials), [`LOUSHO_TOOL_EXECUTION_FAILED`](#lousho_tool_execution_failed), [`LOUSHO_TOOL_ARGS_INVALID`](#lousho_tool_args_invalid), [`LOUSHO_MCP_START_FAILED`](#lousho_mcp_start_failed), [`LOUSHO_MCP_CALL_FAILED`](#lousho_mcp_call_failed) | A tool the model called is unknown, needs credentials, threw, or got invalid arguments; a stdio MCP server did not start or a call to one failed. | @@ -8485,6 +8485,22 @@ names the option and `error.cause` is what the function threw. **Example:** `createAgent({ provider, model: ({ metadata }) => plans[metadata.plan].model })` with an unknown plan. +### LOUSHO_PROMPT_UNRESOLVED + +**Means:** `createAgent({ prompt: 'name@version' })` (or `LOUSHO_PROMPT`) has no +`instructions` fallback and the prompt could not be fetched: the observe daemon +is unreachable, answered 404 for that name and version, or `LOUSHO_OBSERVE` is +not set. The first `send()` rejects and `stream()` ends with an `error` event; +the model is never called, so the agent never runs without a system prompt. The +message names the prompt ref. + +**Fix:** start the daemon (`lousho observe`) and set `LOUSHO_OBSERVE` to its URL, +publish the prompt (`lousho prompt push`), or pass `instructions` as the fallback +(with it set, an unreachable daemon only logs a warning and the run uses the +embedded text). See [Observability](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/observability.md). + +**Example:** `createAgent({ provider, prompt: 'mira@latest' })` with no daemon running. + ## Providers and peers ### LOUSHO_PROVIDER_SPEC_INVALID @@ -14077,7 +14093,8 @@ The daemon also serves two stores agents can use at runtime: - **Prompt registry.** `createAgent({ prompt: 'name@version' })` or `LOUSHO_PROMPT` fetches instructions from it at build (`@latest` resolves - on the daemon; an unreachable daemon falls back to `instructions`). Every + on the daemon; an unreachable daemon falls back to `instructions`; with no + `instructions`, the first run fails with `LOUSHO_PROMPT_UNRESOLVED`). Every run logs the exact version used. `lousho prompt push` publishes a new version. - **Context hub.** `hub()` / `hubTools()` let agents publish and query diff --git a/src/cli/prompt.ts b/src/cli/prompt.ts index 2366857d..2a6ca2a4 100644 --- a/src/cli/prompt.ts +++ b/src/cli/prompt.ts @@ -32,7 +32,8 @@ const HELP = `${USAGE} Manages the prompt registry a 'lousho observe' collector serves. Agents run with createAgent({ prompt: 'name@version' }) or LOUSHO_PROMPT=name@version and fetch their -instructions here at build (falling back to embedded instructions when the daemon is down). +instructions here at build (falling back to embedded instructions when the daemon is down; without +them the run fails with LOUSHO_PROMPT_UNRESOLVED). Options: --name N 'push': the registry name (default: the file's basename, e.g. prompts/mira.txt -> mira; required for '-') diff --git a/src/createAgent.ts b/src/createAgent.ts index dffd0f09..b36d0fc5 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -1565,6 +1565,7 @@ async function resolveOption(option: string, value: PerRun, ctx: RunConfig try { return await value(ctx); } catch (error) { + if (error instanceof SDKError && error.code === 'LOUSHO_PROMPT_UNRESOLVED') throw error; throw new ConfigurationError( `createAgent: the '${option}' function threw while resolving this run's config: ${error instanceof Error ? error.message : String(error)}`, option, @@ -1777,23 +1778,41 @@ function agentInstructions(config: CreateAgentConfig): AgentInstructions { } const env = typeof process !== 'undefined' ? process.env : undefined; const ref = promptIsRef ? (config.prompt as string) : config.prompt === undefined ? env?.LOUSHO_PROMPT : undefined; - const fetched = ref !== undefined ? registryPrompt(ref, { env }) : undefined; + const embedded = promptIsRef ? config.instructions : (config.instructions ?? config.prompt); + // No embedded fallback: an unresolvable ref fails the run (LOUSHO_PROMPT_UNRESOLVED) rather than running with no system prompt. + const failClosed = ref !== undefined && embedded === undefined; + const fetched = ref !== undefined ? registryPrompt(ref, { env, hasFallback: !failClosed }) : undefined; if (ref !== undefined && fetched === undefined) { + if (failClosed) { + return { instructions: () => Promise.reject(promptUnresolved(ref, 'LOUSHO_OBSERVE is not set')), option: 'instructions' }; + } console.warn( `[lousho] prompt: '${ref}' needs a collector to resolve - set LOUSHO_OBSERVE=1 (or a daemon URL); the embedded instructions are used instead.` ); } - const embedded = promptIsRef ? config.instructions : (config.instructions ?? config.prompt); if (fetched === undefined) { return { instructions: embedded, option: config.prompt === undefined ? 'instructions' : 'prompt' }; } return { option: 'instructions', promptUse: fetched.use, - instructions: (ctx) => fetched.text(() => (embedded === undefined ? Promise.resolve(undefined) : resolveOption('instructions', embedded, ctx))), + instructions: (ctx) => + fetched.text(() => { + if (embedded === undefined) return Promise.reject(promptUnresolved(ref as string, 'the observe daemon did not return it')); + return resolveOption('instructions', embedded, ctx); + }), }; } +/** The error a registry prompt with no `instructions` fallback fails the run with. */ +function promptUnresolved(ref: string, why: string): SDKError { + return new SDKError( + `Prompt '${ref}' could not be resolved (${why}) and no \`instructions\` fallback is set, so the agent would run with no system prompt. ` + + "Start the observe daemon (set LOUSHO_OBSERVE to its URL) or add `instructions` as a fallback.", + 'LOUSHO_PROMPT_UNRESOLVED' + ); +} + /** The default `retry` for models given as strings: as many retries as the `ai` SDK makes on its own. */ const DEFAULT_RETRY: WithRetryOptions = { maxRetries: 2 }; diff --git a/src/createAgentPrompt.test.ts b/src/createAgentPrompt.test.ts index 7e8728ea..e9b4fe0e 100644 --- a/src/createAgentPrompt.test.ts +++ b/src/createAgentPrompt.test.ts @@ -104,13 +104,47 @@ describe('createAgent({ prompt }) - fallback + conflicts', () => { expect(warn.mock.calls.filter((c) => String(c[0]).includes('prompt'))).toHaveLength(1); }); - it('falls back to the default instructions when a ref is set but nothing is embedded', async () => { + it('rejects with LOUSHO_PROMPT_UNRESOLVED when the prompt cannot be fetched and there is no fallback', async () => { vi.stubEnv('LOUSHO_OBSERVE', 'http://127.0.0.1:1'); vi.spyOn(console, 'warn').mockImplementation(() => {}); const model = mockModel(['ok']); - await createAgent({ name: 'prompt-agent-e', provider: model, prompt: 'mira@1' }).send('hi'); + const agent = createAgent({ name: 'prompt-agent-e', provider: model, prompt: 'mira@latest' }); + await expect(agent.send('hi')).rejects.toMatchObject({ code: 'LOUSHO_PROMPT_UNRESOLVED', message: expect.stringContaining('mira@latest') }); + await expect(agent.send('hi')).rejects.toThrow(/start the daemon|`instructions`/); + await expect(agent.stream('hi').result).rejects.toMatchObject({ code: 'LOUSHO_PROMPT_UNRESOLVED' }); + expect(model.calls).toHaveLength(0); await closeReporter('prompt-agent-e', 'http://127.0.0.1:1'); - expect(systemPrompt(model)).toContain('You are a helpful assistant.'); + }); + + it('rejects with LOUSHO_PROMPT_UNRESOLVED when the daemon answers 404 for the prompt', async () => { + const daemon = await start(); + vi.stubEnv('LOUSHO_OBSERVE', base(daemon)); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const model = mockModel(['ok']); + await expect(createAgent({ name: 'prompt-agent-404', provider: model, prompt: 'ghost@latest' }).send('hi')).rejects.toMatchObject({ + code: 'LOUSHO_PROMPT_UNRESOLVED', + }); + expect(model.calls).toHaveLength(0); + await closeReporter('prompt-agent-404', base(daemon)); + }); + + it('rejects with LOUSHO_PROMPT_UNRESOLVED when no collector is configured and there is no fallback', async () => { + vi.stubEnv('LOUSHO_OBSERVE', ''); + const model = mockModel(['ok']); + await expect(createAgent({ name: 'prompt-agent-nocollector', provider: model, prompt: 'mira@latest' }).send('hi')).rejects.toMatchObject({ + code: 'LOUSHO_PROMPT_UNRESOLVED', + message: expect.stringContaining('LOUSHO_OBSERVE'), + }); + expect(model.calls).toHaveLength(0); + }); + + it('falls back to instructions when they exist (no collector: warning text unchanged)', async () => { + vi.stubEnv('LOUSHO_OBSERVE', ''); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const model = mockModel(['ok']); + await createAgent({ name: 'prompt-agent-fb', provider: model, prompt: 'mira@1', instructions: 'You are embedded.' }).send('hi'); + expect(systemPrompt(model)).toContain('You are embedded.'); + expect(String(warn.mock.calls[0][0])).toContain('the embedded instructions are used instead.'); }); it('still treats a non-ref prompt + instructions as the conflicting-aliases error', () => { diff --git a/src/observe/promptClient.ts b/src/observe/promptClient.ts index ed998899..67e9e395 100644 --- a/src/observe/promptClient.ts +++ b/src/observe/promptClient.ts @@ -120,11 +120,12 @@ export interface RegistryPrompt { /** * Resolve `ref` (`'name@version'`) against the daemon `env`'s * `LOUSHO_OBSERVE` points at. Returns `undefined` - the opt-in does not - * apply - when the env names no daemon; `LOUSHO_OBSERVE_TOKEN` is sent when set. + * apply - when the env names no daemon (`hasFallback: false` - no embedded + * instructions - only changes the warning's wording); `LOUSHO_OBSERVE_TOKEN` is sent when set. */ export function registryPrompt( ref: string, - options: { env?: NodeJS.ProcessEnv; fetchFn?: typeof fetch; timeoutMs?: number } = {} + options: { env?: NodeJS.ProcessEnv; fetchFn?: typeof fetch; timeoutMs?: number; hasFallback?: boolean } = {} ): RegistryPrompt | undefined { const parsed = parsePromptRef(ref); const env = options.env ?? (typeof process !== 'undefined' ? process.env : undefined); @@ -147,7 +148,9 @@ export function registryPrompt( warned = true; console.warn( `[lousho] prompt: could not resolve '${ref}' at ${url} (${error instanceof Error ? error.message : String(error)}); ` + - 'the embedded instructions are used instead.' + (options.hasFallback === false + ? 'there are no embedded instructions, so runs fail with LOUSHO_PROMPT_UNRESOLVED.' + : 'the embedded instructions are used instead.') ); } return undefined; diff --git a/src/utils/errorCodes.ts b/src/utils/errorCodes.ts index 85f9dcc3..dc434630 100644 --- a/src/utils/errorCodes.ts +++ b/src/utils/errorCodes.ts @@ -46,6 +46,7 @@ export const ERROR_CODES = { LOUSHO_REGISTRY_UNSAFE_PATH: 'Do not install this item; it asks to write outside its allowed folder or is too large, so tell the registry owner.', LOUSHO_REGISTRY_FILE_EXISTS: 'Pass --overwrite to replace the existing file, or move your file away first.', LOUSHO_REGISTRY_MANIFEST_MISMATCH: 'Do not install this item: its code reaches for something its permission manifest does not declare; tell the registry owner, who fixes the code or the manifest.', + LOUSHO_PROMPT_UNRESOLVED: 'Start the observe daemon (set LOUSHO_OBSERVE to its URL), or pass `instructions` as a fallback.', LOUSHO_TOOL_NOT_FOUND: 'Use one of the tool names listed in the message, or register the tool yourself.', LOUSHO_TOOL_NEEDS_CREDENTIALS: 'Build the agent with createAgent() and pass the configured tool.', LOUSHO_TOOL_EXECUTION_FAILED: "Look at the tool's own error (the `cause`) and fix the tool or its input.", From f71c82b80efdca0f23dc70c835b91f5e3eb26648 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sat, 24 Oct 2026 17:23:58 +0300 Subject: [PATCH 4/4] fix(prompt): retry a failed registry prompt fetch on the next run A failed resolution was cached for the agent's lifetime, so with fail-closed a daemon briefly down at build made every later run reject. A failure is now dropped so the next run retries; successes stay cached. Adds retry, env-ref and specific-message tests, splits agentInstructions to keep complexity down, and notes the retry in docs/observability.md. Co-Authored-By: Claude Opus 5.5 --- docs/observability.md | 6 ++++-- llms-full.txt | 6 ++++-- src/createAgent.ts | 31 +++++++++++++++++-------------- src/createAgentPrompt.test.ts | 35 ++++++++++++++++++++++++++++++++++- src/observe/promptClient.ts | 25 ++++++++++++++++++------- 5 files changed, 77 insertions(+), 26 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index 98fecbba..e37be88b 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -418,8 +418,10 @@ The daemon also serves two stores agents can use at runtime: - **Prompt registry.** `createAgent({ prompt: 'name@version' })` or `LOUSHO_PROMPT` fetches instructions from it at build (`@latest` resolves on the daemon; an unreachable daemon falls back to `instructions`; with no - `instructions`, the first run fails with `LOUSHO_PROMPT_UNRESOLVED`). Every - run logs the exact version used. `lousho prompt push` publishes a new + `instructions`, the run fails with `LOUSHO_PROMPT_UNRESOLVED`). A failed + fetch is not cached: the next run retries it, so a daemon that was briefly + down at build recovers without a restart. Every run logs the exact version + used. `lousho prompt push` publishes a new version. - **Context hub.** `hub()` / `hubTools()` let agents publish and query namespaced key-values scoped `private` (the writing agent only) or diff --git a/llms-full.txt b/llms-full.txt index 52f2c685..09cf3b6a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -14094,8 +14094,10 @@ The daemon also serves two stores agents can use at runtime: - **Prompt registry.** `createAgent({ prompt: 'name@version' })` or `LOUSHO_PROMPT` fetches instructions from it at build (`@latest` resolves on the daemon; an unreachable daemon falls back to `instructions`; with no - `instructions`, the first run fails with `LOUSHO_PROMPT_UNRESOLVED`). Every - run logs the exact version used. `lousho prompt push` publishes a new + `instructions`, the run fails with `LOUSHO_PROMPT_UNRESOLVED`). A failed + fetch is not cached: the next run retries it, so a daemon that was briefly + down at build recovers without a restart. Every run logs the exact version + used. `lousho prompt push` publishes a new version. - **Context hub.** `hub()` / `hubTools()` let agents publish and query namespaced key-values scoped `private` (the writing agent only) or diff --git a/src/createAgent.ts b/src/createAgent.ts index b36d0fc5..a3bae4a1 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -1779,28 +1779,31 @@ function agentInstructions(config: CreateAgentConfig): AgentInstructions { const env = typeof process !== 'undefined' ? process.env : undefined; const ref = promptIsRef ? (config.prompt as string) : config.prompt === undefined ? env?.LOUSHO_PROMPT : undefined; const embedded = promptIsRef ? config.instructions : (config.instructions ?? config.prompt); - // No embedded fallback: an unresolvable ref fails the run (LOUSHO_PROMPT_UNRESOLVED) rather than running with no system prompt. - const failClosed = ref !== undefined && embedded === undefined; - const fetched = ref !== undefined ? registryPrompt(ref, { env, hasFallback: !failClosed }) : undefined; - if (ref !== undefined && fetched === undefined) { - if (failClosed) { - return { instructions: () => Promise.reject(promptUnresolved(ref, 'LOUSHO_OBSERVE is not set')), option: 'instructions' }; - } + if (ref === undefined) return { instructions: embedded, option: config.prompt === undefined ? 'instructions' : 'prompt' }; + return registryInstructions(ref, embedded, env); +} + +/** The instructions of an agent with a registry `ref`: the fetched text, `embedded` as the fallback, or (without it) a run-time LOUSHO_PROMPT_UNRESOLVED. */ +function registryInstructions(ref: string, embedded: PerRun | undefined, env: NodeJS.ProcessEnv | undefined): AgentInstructions { + // No embedded fallback: an unresolvable ref fails the run rather than running with no system prompt. + const failClosed = embedded === undefined; + const fetched = registryPrompt(ref, { env, hasFallback: !failClosed }); + if (fetched === undefined) { + if (failClosed) return { instructions: () => Promise.reject(promptUnresolved(ref, 'LOUSHO_OBSERVE is not set')), option: 'instructions' }; console.warn( `[lousho] prompt: '${ref}' needs a collector to resolve - set LOUSHO_OBSERVE=1 (or a daemon URL); the embedded instructions are used instead.` ); - } - if (fetched === undefined) { - return { instructions: embedded, option: config.prompt === undefined ? 'instructions' : 'prompt' }; + return { instructions: embedded, option: 'instructions' }; } return { option: 'instructions', promptUse: fetched.use, instructions: (ctx) => - fetched.text(() => { - if (embedded === undefined) return Promise.reject(promptUnresolved(ref as string, 'the observe daemon did not return it')); - return resolveOption('instructions', embedded, ctx); - }), + fetched.text(() => + embedded === undefined + ? Promise.reject(promptUnresolved(ref, 'the observe daemon did not return it')) + : resolveOption('instructions', embedded, ctx) + ), }; } diff --git a/src/createAgentPrompt.test.ts b/src/createAgentPrompt.test.ts index e9b4fe0e..9c3f0e66 100644 --- a/src/createAgentPrompt.test.ts +++ b/src/createAgentPrompt.test.ts @@ -110,7 +110,7 @@ describe('createAgent({ prompt }) - fallback + conflicts', () => { const model = mockModel(['ok']); const agent = createAgent({ name: 'prompt-agent-e', provider: model, prompt: 'mira@latest' }); await expect(agent.send('hi')).rejects.toMatchObject({ code: 'LOUSHO_PROMPT_UNRESOLVED', message: expect.stringContaining('mira@latest') }); - await expect(agent.send('hi')).rejects.toThrow(/start the daemon|`instructions`/); + await expect(agent.send('hi')).rejects.toThrow(/mira@latest.*LOUSHO_OBSERVE/s); await expect(agent.stream('hi').result).rejects.toMatchObject({ code: 'LOUSHO_PROMPT_UNRESOLVED' }); expect(model.calls).toHaveLength(0); await closeReporter('prompt-agent-e', 'http://127.0.0.1:1'); @@ -138,6 +138,39 @@ describe('createAgent({ prompt }) - fallback + conflicts', () => { expect(model.calls).toHaveLength(0); }); + it('rejects with LOUSHO_PROMPT_UNRESOLVED for a LOUSHO_PROMPT env ref with no instructions', async () => { + vi.stubEnv('LOUSHO_OBSERVE', 'http://127.0.0.1:1'); + vi.stubEnv('LOUSHO_PROMPT', 'envref@latest'); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const model = mockModel(['ok']); + await expect(createAgent({ name: 'prompt-agent-envref', provider: model }).send('hi')).rejects.toMatchObject({ + code: 'LOUSHO_PROMPT_UNRESOLVED', + message: expect.stringContaining('envref@latest'), + }); + expect(model.calls).toHaveLength(0); + await closeReporter('prompt-agent-envref', 'http://127.0.0.1:1'); + }); + + it('retries a failed resolution: the next run succeeds once the daemon is reachable', async () => { + const first = await start(); + const port = first.port; + await first.close(); + daemons.splice(daemons.indexOf(first), 1); + const url = `http://127.0.0.1:${port}`; + vi.stubEnv('LOUSHO_OBSERVE', url); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const model = mockModel(['ok']); + const agent = createAgent({ name: 'prompt-agent-retry', provider: model, prompt: 'mira@latest' }); + await expect(agent.send('hi')).rejects.toMatchObject({ code: 'LOUSHO_PROMPT_UNRESOLVED' }); + + const daemon = await startObserveDaemon({ dbPath: ':memory:', port }); + daemons.push(daemon); + await publishPrompt(url, { name: 'mira', text: 'Registry text after recovery.' }); + await agent.send('hi'); + expect(systemPrompt(model)).toContain('Registry text after recovery.'); + await closeReporter('prompt-agent-retry', url); + }); + it('falls back to instructions when they exist (no collector: warning text unchanged)', async () => { vi.stubEnv('LOUSHO_OBSERVE', ''); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/src/observe/promptClient.ts b/src/observe/promptClient.ts index 67e9e395..97b244da 100644 --- a/src/observe/promptClient.ts +++ b/src/observe/promptClient.ts @@ -10,8 +10,8 @@ * history calls back `lousho prompt push|list|get`. * * `registryPrompt()` is the createAgent wiring: it starts the fetch when the - * agent is BUILT (the spec's "fetch at build"), memoizes it for the agent's - * lifetime, and hands the reporter a `prompt_use` ref once the fetch landed. + * agent is BUILT (the spec's "fetch at build"), memoizes a successful fetch for the + * agent's lifetime (a failed one is retried by the next run), and hands the reporter a `prompt_use` ref once the fetch landed. */ import { observeRequest } from './httpClient'; @@ -105,8 +105,8 @@ export async function listPrompts(baseUrl: string, filter: { name?: string; limi /** * The resolved-or-fallback prompt a `prompt` ref produces for one agent. The * fetch starts at construction (agent build) and is memoized for the agent's - * lifetime; a failure warns once and every run then uses the embedded - * instructions. + * lifetime; a failure warns once, that run uses the embedded instructions and + * the next run retries the fetch. */ export interface RegistryPrompt { /** The fetched text, or `fallback()` when the fetch failed. */ @@ -138,7 +138,7 @@ export function registryPrompt( }; let warned = false; let resolved: ResolvedPrompt | undefined; - const pending = (async () => { + const attempt = async (): Promise => { try { const prompt = await fetchPrompt(url, parsed, fetchOptions); resolved = prompt; @@ -155,10 +155,21 @@ export function registryPrompt( } return undefined; } - })(); + }; + // A success stays cached; a failure is dropped so the next run retries (a daemon that was briefly down at build recovers). + let pending: Promise | undefined; + const start = (): Promise => { + const current = attempt(); + pending = current; + void current.then((prompt) => { + if (prompt === undefined && pending === current) pending = undefined; + }); + return current; + }; + void start(); return { ref: parsed, - text: async (fallback) => (await pending)?.text ?? (await fallback()), + text: async (fallback) => (await (pending ?? start()))?.text ?? (await fallback()), use: () => (resolved !== undefined ? { prompt_id: resolved.prompt_id, version: resolved.version } : undefined), }; }