From c75847913bc6248b9fa729ffc1d4af0e222b4191 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sat, 24 Oct 2026 17:06:30 +0300 Subject: [PATCH 1/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 d03f3b05..a323a729 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 31a534c0..75543eeb 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -8397,7 +8397,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. | @@ -8491,6 +8491,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 @@ -14083,7 +14099,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 cee76a7b..d864c727 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -1564,6 +1564,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, @@ -1776,23 +1777,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 41a8387b890c3141aefd74b45a3eeb3254217b9b Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sat, 24 Oct 2026 17:23:58 +0300 Subject: [PATCH 2/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 a323a729..bc3b14ac 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 75543eeb..7f4bff82 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -14100,8 +14100,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 d864c727..01bc2f0d 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -1778,28 +1778,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), }; } From 04c2e3aabfeae714bfb3a71fa71b9f9ff68d648a Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sat, 24 Oct 2026 17:29:15 +0300 Subject: [PATCH 3/4] fix(prompt): back off 30s after a failed registry prompt fetch Retrying on every run made each run wait up to 2.5s on a down daemon, even for agents with an instructions fallback. A failed fetch is now remembered for 30s: runs in the window resolve at once (fallback instructions, or LOUSHO_PROMPT_UNRESOLVED), then one run retries. Also restores the resolver option name in the no-fetch fallback path. Co-Authored-By: Claude Opus 5.5 --- docs/observability.md | 5 ++-- llms-full.txt | 5 ++-- src/createAgent.ts | 6 ++--- src/createAgentPrompt.test.ts | 43 ++++++++++++++++++++++++++++++++++- src/observe/promptClient.ts | 21 +++++++++++++---- 5 files changed, 67 insertions(+), 13 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index bc3b14ac..27f05183 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -419,8 +419,9 @@ The daemon also serves two stores agents can use at runtime: `LOUSHO_PROMPT` fetches instructions from it at build (`@latest` resolves on the daemon; an unreachable daemon falls back to `instructions`; with no `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 + fetch is not cached for good: runs in the next 30 seconds resolve at once + (using `instructions`, or failing), then 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 diff --git a/llms-full.txt b/llms-full.txt index 7f4bff82..cc3f58f3 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -14101,8 +14101,9 @@ The daemon also serves two stores agents can use at runtime: `LOUSHO_PROMPT` fetches instructions from it at build (`@latest` resolves on the daemon; an unreachable daemon falls back to `instructions`; with no `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 + fetch is not cached for good: runs in the next 30 seconds resolve at once + (using `instructions`, or failing), then 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 diff --git a/src/createAgent.ts b/src/createAgent.ts index 01bc2f0d..909148a1 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -1779,11 +1779,11 @@ function agentInstructions(config: CreateAgentConfig): AgentInstructions { const ref = promptIsRef ? (config.prompt as string) : config.prompt === undefined ? env?.LOUSHO_PROMPT : undefined; const embedded = promptIsRef ? config.instructions : (config.instructions ?? config.prompt); if (ref === undefined) return { instructions: embedded, option: config.prompt === undefined ? 'instructions' : 'prompt' }; - return registryInstructions(ref, embedded, env); + return registryInstructions(ref, embedded, env, config.prompt === undefined ? 'instructions' : 'prompt'); } /** 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 { +function registryInstructions(ref: string, embedded: PerRun | undefined, env: NodeJS.ProcessEnv | undefined, option: AgentInstructions['option']): 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 }); @@ -1792,7 +1792,7 @@ function registryInstructions(ref: string, embedded: PerRun console.warn( `[lousho] prompt: '${ref}' needs a collector to resolve - set LOUSHO_OBSERVE=1 (or a daemon URL); the embedded instructions are used instead.` ); - return { instructions: embedded, option: 'instructions' }; + return { instructions: embedded, option }; } return { option: 'instructions', diff --git a/src/createAgentPrompt.test.ts b/src/createAgentPrompt.test.ts index 9c3f0e66..fd3d7e09 100644 --- a/src/createAgentPrompt.test.ts +++ b/src/createAgentPrompt.test.ts @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createAgent } from './createAgent'; import { startObserveDaemon, type ObserveDaemon } from './observe/daemon'; -import { publishPrompt } from './observe/promptClient'; +import { PROMPT_RETRY_WINDOW_MS, publishPrompt } from './observe/promptClient'; import { observeExporterFromEnv } from './observe/reporter'; import { mockModel, type MockModel } from './testing'; import { textOf } from './providers/content'; @@ -20,6 +20,7 @@ beforeEach(() => { }); afterEach(async () => { + vi.useRealTimers(); vi.unstubAllEnvs(); vi.restoreAllMocks(); while (daemons.length > 0) await daemons.pop()?.close(); @@ -159,6 +160,7 @@ describe('createAgent({ prompt }) - fallback + conflicts', () => { const url = `http://127.0.0.1:${port}`; vi.stubEnv('LOUSHO_OBSERVE', url); vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.useFakeTimers({ toFake: ['Date'] }); 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' }); @@ -166,11 +168,50 @@ describe('createAgent({ prompt }) - fallback + conflicts', () => { const daemon = await startObserveDaemon({ dbPath: ':memory:', port }); daemons.push(daemon); await publishPrompt(url, { name: 'mira', text: 'Registry text after recovery.' }); + // Inside the retry window the failure stands, even though the daemon is back. + await expect(agent.send('hi')).rejects.toMatchObject({ code: 'LOUSHO_PROMPT_UNRESOLVED' }); + vi.setSystemTime(Date.now() + PROMPT_RETRY_WINDOW_MS + 1); await agent.send('hi'); expect(systemPrompt(model)).toContain('Registry text after recovery.'); await closeReporter('prompt-agent-retry', url); }); + it('with a fallback and the daemon down, runs inside the retry window make no network call; the first run after it retries', 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(() => {}); + vi.useFakeTimers({ toFake: ['Date'] }); + const realFetch = globalThis.fetch; + const promptFetches = vi.fn(); + vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => { + if (String(input).includes('/api/v1/prompts')) promptFetches(); + return realFetch(input, init); + }); + const model = mockModel(['ok', 'ok', 'ok']); + const agent = createAgent({ name: 'prompt-agent-window', provider: model, prompt: 'mira@latest', instructions: 'You are embedded.' }); + await agent.send('hi'); + expect(systemPrompt(model, 0)).toContain('You are embedded.'); + const afterFirst = promptFetches.mock.calls.length; + + const started = performance.now(); + await agent.send('hi again'); + expect(performance.now() - started).toBeLessThan(1000); + expect(promptFetches.mock.calls.length).toBe(afterFirst); + expect(systemPrompt(model, 1)).toContain('You are embedded.'); + + const daemon = await startObserveDaemon({ dbPath: ':memory:', port }); + daemons.push(daemon); + await publishPrompt(url, { name: 'mira', text: 'Registry text after the window.' }); + vi.setSystemTime(Date.now() + PROMPT_RETRY_WINDOW_MS + 1); + await agent.send('hi once more'); + expect(systemPrompt(model, 2)).toContain('Registry text after the window.'); + await closeReporter('prompt-agent-window', 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 97b244da..05187e20 100644 --- a/src/observe/promptClient.ts +++ b/src/observe/promptClient.ts @@ -11,7 +11,7 @@ * * `registryPrompt()` is the createAgent wiring: it starts the fetch when the * 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. + * agent's lifetime (a failed one is retried by the first run after PROMPT_RETRY_WINDOW_MS), and hands the reporter a `prompt_use` ref once the fetch landed. */ import { observeRequest } from './httpClient'; @@ -62,6 +62,9 @@ export interface PromptClientOptions { const DEFAULT_TIMEOUT_MS = 2500; +/** After a failed prompt fetch, runs resolve as failed immediately for this long before one run retries. */ +export const PROMPT_RETRY_WINDOW_MS = 30_000; + /** Error body shape the daemon answers; the message is surfaced verbatim. */ function request(baseUrl: string, path: string, init: RequestInit, options: PromptClientOptions): Promise { return observeRequest(baseUrl, path, init, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options); @@ -105,8 +108,9 @@ 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, that run uses the embedded instructions and - * the next run retries the fetch. + * lifetime; a failure warns once, runs in the next + * PROMPT_RETRY_WINDOW_MS use the embedded instructions without a network await, + * then one run retries the fetch. */ export interface RegistryPrompt { /** The fetched text, or `fallback()` when the fetch failed. */ @@ -153,10 +157,13 @@ export function registryPrompt( : 'the embedded instructions are used instead.') ); } + failedAt = Date.now(); return undefined; } }; - // A success stays cached; a failure is dropped so the next run retries (a daemon that was briefly down at build recovers). + // A success stays cached. A failure is remembered for PROMPT_RETRY_WINDOW_MS (runs in it fail at once, no network await), + // then one run retries - so a daemon that was briefly down at build recovers without a restart. + let failedAt: number | undefined; let pending: Promise | undefined; const start = (): Promise => { const current = attempt(); @@ -169,7 +176,11 @@ export function registryPrompt( void start(); return { ref: parsed, - text: async (fallback) => (await (pending ?? start()))?.text ?? (await fallback()), + text: async (fallback) => { + const inBackoff = pending === undefined && failedAt !== undefined && Date.now() - failedAt < PROMPT_RETRY_WINDOW_MS; + const prompt = inBackoff ? undefined : await (pending ?? start()); + return prompt?.text ?? (await fallback()); + }, use: () => (resolved !== undefined ? { prompt_id: resolved.prompt_id, version: resolved.version } : undefined), }; } From 26bd8baa3d36ffad152ce030aaf141e67e2d832c Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sat, 24 Oct 2026 18:14:23 +0300 Subject: [PATCH 4/4] docs(errors): document the LOUSHO_PROMPT_UNRESOLVED code entry for the typedoc ratchet Co-Authored-By: Claude Opus 5.5 --- src/utils/errorCodes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/errorCodes.ts b/src/utils/errorCodes.ts index dc434630..80c7acf3 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.', + /** A registry `prompt` ref with no `instructions` fallback could not be fetched, so the run is refused. */ 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.',