mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-21 18:30:15 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee7f891698 | |||
| 7fde348e2b | |||
| 122135e6dc | |||
| 8563fb0681 | |||
| 0155ab1943 | |||
| d1f94fcd24 | |||
| dd60983f7f | |||
| 096b8bf589 | |||
| 59c4d33adc | |||
| 5f5c28d18d | |||
| e03c5c194a | |||
| 01433bae32 |
@@ -121,6 +121,7 @@ Bucket the upstream changed files:
|
|||||||
- **Host source** (`src/`): may conflict if user modified the same files
|
- **Host source** (`src/`): may conflict if user modified the same files
|
||||||
- **Container** (`container/`): triggers container rebuild (+ typecheck if `agent-runner/src/` changed)
|
- **Container** (`container/`): triggers container rebuild (+ typecheck if `agent-runner/src/` changed)
|
||||||
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install
|
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install
|
||||||
|
- **Version pins** (`versions.json`): a changed `onecli-gateway` / `onecli-cli` value requires upgrading the OneCLI gateway/CLI to match — see Step 5.5
|
||||||
- **Other**: docs, tests, setup scripts, misc
|
- **Other**: docs, tests, setup scripts, misc
|
||||||
|
|
||||||
**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push.
|
**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push.
|
||||||
@@ -215,6 +216,11 @@ If build fails:
|
|||||||
- Do not refactor unrelated code.
|
- Do not refactor unrelated code.
|
||||||
- If unclear, ask the user before making changes.
|
- If unclear, ask the user before making changes.
|
||||||
|
|
||||||
|
# Step 5.5: OneCLI upgrade (if pins moved)
|
||||||
|
The OneCLI gateway and CLI are external components pinned in `versions.json`; when a pin moves, the running version must be upgraded to match or the new code may fail against it.
|
||||||
|
|
||||||
|
If `git diff <backup-tag-from-step-1>..HEAD -- versions.json` shows the `onecli-gateway` or `onecli-cli` value changed, follow `docs/onecli-upgrades.md` before the service restart (Step 8). Otherwise skip.
|
||||||
|
|
||||||
# Step 6: Breaking changes check
|
# Step 6: Breaking changes check
|
||||||
After validation succeeds, check if the update introduced any breaking changes.
|
After validation succeeds, check if the update introduced any breaking changes.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`; the `onecli` setup step enforces them. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
|
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
|
||||||
|
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`. **The gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
|
||||||
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
|
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
|
||||||
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
|
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
|
||||||
- **Provider choice is explicit per group — no install-wide default.** Provider is a DB property set via `ncl groups config update --provider` + restart; creation is provider-agnostic.
|
- **Provider choice is explicit per group — no install-wide default.** Provider is a DB property set via `ncl groups config update --provider` + restart; creation is provider-agnostic.
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '
|
|||||||
import { getPendingMessages, markCompleted } from './db/messages-in.js';
|
import { getPendingMessages, markCompleted } from './db/messages-in.js';
|
||||||
import { getUndeliveredMessages } from './db/messages-out.js';
|
import { getUndeliveredMessages } from './db/messages-out.js';
|
||||||
import { formatMessages, extractRouting } from './formatter.js';
|
import { formatMessages, extractRouting } from './formatter.js';
|
||||||
import { isCorruptionError } from './poll-loop.js';
|
import { isCorruptionError, processQuery } from './poll-loop.js';
|
||||||
import { MockProvider } from './providers/mock.js';
|
import { MockProvider } from './providers/mock.js';
|
||||||
|
import type { AgentQuery, ProviderEvent } from './providers/types.js';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
initTestSessionDb();
|
initTestSessionDb();
|
||||||
@@ -379,6 +380,64 @@ describe('end-to-end with mock provider', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a one-shot stub query that yields init + a single result event, then
|
||||||
|
* ends. `pushes` records any follow-ups the loop tried to inject (e.g. the
|
||||||
|
* re-wrap nudge), so a test can assert the loop did NOT re-hammer.
|
||||||
|
*/
|
||||||
|
function makeResultQuery(result: ProviderEvent): { query: AgentQuery; pushes: string[] } {
|
||||||
|
const pushes: string[] = [];
|
||||||
|
async function* events(): AsyncGenerator<ProviderEvent> {
|
||||||
|
yield { type: 'init', continuation: 'sess-1' };
|
||||||
|
yield result;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pushes,
|
||||||
|
query: {
|
||||||
|
push: (m: string) => {
|
||||||
|
pushes.push(m);
|
||||||
|
},
|
||||||
|
end: () => {},
|
||||||
|
events: events(),
|
||||||
|
abort: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERR_ROUTING = {
|
||||||
|
platformId: 'chan-1',
|
||||||
|
channelType: 'discord',
|
||||||
|
threadId: null,
|
||||||
|
inReplyTo: 'm1',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('error result with no <message> envelope', () => {
|
||||||
|
it('delivers a budget/billing error to the triggering channel and does not nudge', async () => {
|
||||||
|
const budgetText = 'Spending limit reached. Add your own key at https://example.com/keys';
|
||||||
|
const { query, pushes } = makeResultQuery({ type: 'result', text: budgetText, isError: true });
|
||||||
|
|
||||||
|
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
|
||||||
|
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(JSON.parse(out[0].content).text).toBe(budgetText);
|
||||||
|
expect(out[0].platform_id).toBe('chan-1');
|
||||||
|
expect(out[0].channel_type).toBe('discord');
|
||||||
|
// No re-wrap nudge — an error result must not re-hammer the gateway.
|
||||||
|
expect(pushes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still nudges (and does not deliver) a normal unwrapped result', async () => {
|
||||||
|
const { query, pushes } = makeResultQuery({ type: 'result', text: 'bare text, no envelope' });
|
||||||
|
|
||||||
|
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
|
||||||
|
|
||||||
|
expect(getUndeliveredMessages()).toHaveLength(0);
|
||||||
|
expect(pushes).toHaveLength(1);
|
||||||
|
expect(pushes[0]).toContain('was not delivered');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isCorruptionError', () => {
|
describe('isCorruptionError', () => {
|
||||||
it('matches the Docker Desktop macOS torn-read symptom', () => {
|
it('matches the Docker Desktop macOS torn-read symptom', () => {
|
||||||
expect(isCorruptionError('database disk image is malformed')).toBe(true);
|
expect(isCorruptionError('database disk image is malformed')).toBe(true);
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ interface QueryResult {
|
|||||||
continuation?: string;
|
continuation?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processQuery(
|
export async function processQuery(
|
||||||
query: AgentQuery,
|
query: AgentQuery,
|
||||||
routing: RoutingContext,
|
routing: RoutingContext,
|
||||||
initialBatchIds: string[],
|
initialBatchIds: string[],
|
||||||
@@ -482,28 +482,43 @@ async function processQuery(
|
|||||||
// at all — either way the turn is finished.
|
// at all — either way the turn is finished.
|
||||||
markCompleted(initialBatchIds);
|
markCompleted(initialBatchIds);
|
||||||
if (event.text) {
|
if (event.text) {
|
||||||
const { hasUnwrapped } = dispatchResultText(event.text, routing);
|
const { sent, hasUnwrapped } = dispatchResultText(event.text, routing);
|
||||||
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
|
if (sent === 0 && event.isError === true) {
|
||||||
notifyExchangeComplete(onExchangeComplete, {
|
// Non-retryable error turn (e.g. a 403 billing_error) with no
|
||||||
prompt: archivePrompts[0] ?? initialPrompt,
|
// <message> envelope: deliver the notice instead of dropping it as
|
||||||
result: event.text,
|
// scratchpad, and skip the re-wrap nudge — it would just re-hammer
|
||||||
continuation: queryContinuation ?? initialContinuation,
|
// the failing gateway turn after turn.
|
||||||
status: hasUnwrapped ? 'undelivered' : 'completed',
|
deliverErrorResult(event.text, routing);
|
||||||
});
|
notifyExchangeComplete(onExchangeComplete, {
|
||||||
if (willRetryWrapping) {
|
prompt: archivePrompts[0] ?? initialPrompt,
|
||||||
unwrappedNudged = true;
|
result: event.text,
|
||||||
const destinations = getAllDestinations();
|
continuation: queryContinuation ?? initialContinuation,
|
||||||
const names = destinations.map((d) => d.name).join(', ');
|
status: 'error',
|
||||||
query.push(
|
});
|
||||||
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
|
archivePrompts.shift();
|
||||||
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
|
} else {
|
||||||
`Your destinations: ${names}. ` +
|
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
|
||||||
`Please re-send your response with the correct wrapping.</system>`,
|
notifyExchangeComplete(onExchangeComplete, {
|
||||||
);
|
prompt: archivePrompts[0] ?? initialPrompt,
|
||||||
|
result: event.text,
|
||||||
|
continuation: queryContinuation ?? initialContinuation,
|
||||||
|
status: hasUnwrapped ? 'undelivered' : 'completed',
|
||||||
|
});
|
||||||
|
if (willRetryWrapping) {
|
||||||
|
unwrappedNudged = true;
|
||||||
|
const destinations = getAllDestinations();
|
||||||
|
const names = destinations.map((d) => d.name).join(', ');
|
||||||
|
query.push(
|
||||||
|
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
|
||||||
|
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
|
||||||
|
`Your destinations: ${names}. ` +
|
||||||
|
`Please re-send your response with the correct wrapping.</system>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The wrapping-retry result answers the SAME user prompt — keep it
|
||||||
|
// queued so the retry archives against it, not the nudge text.
|
||||||
|
if (!willRetryWrapping) archivePrompts.shift();
|
||||||
}
|
}
|
||||||
// The wrapping-retry result answers the SAME user prompt — keep it
|
|
||||||
// queued so the retry archives against it, not the nudge text.
|
|
||||||
if (!willRetryWrapping) archivePrompts.shift();
|
|
||||||
} else {
|
} else {
|
||||||
archivePrompts.shift();
|
archivePrompts.shift();
|
||||||
}
|
}
|
||||||
@@ -557,6 +572,26 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver a turn's text straight to the channel the batch arrived on. Used when
|
||||||
|
* a turn ends in a provider error (e.g. a non-retryable 403 billing_error) with
|
||||||
|
* no <message> envelope: the notice would otherwise be dropped as scratchpad.
|
||||||
|
* This is the same user-facing write the outer catch block does, minus the
|
||||||
|
* `Error:` prefix — the provider's text is already a user-facing message.
|
||||||
|
*/
|
||||||
|
function deliverErrorResult(text: string, routing: RoutingContext): void {
|
||||||
|
log('Error result with no <message> envelope — delivering to channel');
|
||||||
|
writeMessageOut({
|
||||||
|
id: generateId(),
|
||||||
|
in_reply_to: routing.inReplyTo,
|
||||||
|
kind: 'chat',
|
||||||
|
platform_id: routing.platformId,
|
||||||
|
channel_type: routing.channelType,
|
||||||
|
thread_id: routing.threadId,
|
||||||
|
content: JSON.stringify({ text }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the agent's final text for <message to="name">...</message> blocks
|
* Parse the agent's final text for <message to="name">...</message> blocks
|
||||||
* and dispatch each one to its resolved destination. Text outside of blocks
|
* and dispatch each one to its resolved destination. Text outside of blocks
|
||||||
|
|||||||
@@ -440,8 +440,13 @@ export class ClaudeProvider implements AgentProvider {
|
|||||||
if (message.type === 'system' && message.subtype === 'init') {
|
if (message.type === 'system' && message.subtype === 'init') {
|
||||||
yield { type: 'init', continuation: message.session_id };
|
yield { type: 'init', continuation: message.session_id };
|
||||||
} else if (message.type === 'result') {
|
} else if (message.type === 'result') {
|
||||||
const text = 'result' in message ? (message as { result?: string }).result ?? null : null;
|
// `result` text exists only on subtype:"success"; error subtypes
|
||||||
yield { type: 'result', text };
|
// (e.g. a non-retryable 403 billing_error) carry their message in
|
||||||
|
// `errors[]` instead. Surface either so the poll-loop can deliver a
|
||||||
|
// billing/quota notice to the user rather than dropping the turn.
|
||||||
|
const m = message as { result?: string; is_error?: boolean; errors?: string[] };
|
||||||
|
const text = m.result ?? (m.errors && m.errors.length > 0 ? m.errors.join('\n') : null);
|
||||||
|
yield { type: 'result', text, isError: m.is_error === true };
|
||||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') {
|
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') {
|
||||||
yield { type: 'error', message: 'API retry', retryable: true };
|
yield { type: 'error', message: 'API retry', retryable: true };
|
||||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') {
|
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') {
|
||||||
|
|||||||
@@ -125,7 +125,13 @@ export interface AgentQuery {
|
|||||||
|
|
||||||
export type ProviderEvent =
|
export type ProviderEvent =
|
||||||
| { type: 'init'; continuation: string }
|
| { type: 'init'; continuation: string }
|
||||||
| { type: 'result'; text: string | null }
|
/**
|
||||||
|
* A completed turn. `isError` is set when the underlying SDK flagged the
|
||||||
|
* turn as an error (e.g. a non-retryable Anthropic 403 billing_error). The
|
||||||
|
* poll-loop uses it to surface the result text to the user instead of
|
||||||
|
* dropping it as un-wrapped scratchpad, and to skip the re-wrap nudge.
|
||||||
|
*/
|
||||||
|
| { type: 'result'; text: string | null; isError?: boolean }
|
||||||
| { type: 'error'; message: string; retryable: boolean; classification?: string }
|
| { type: 'error'; message: string; retryable: boolean; classification?: string }
|
||||||
| { type: 'progress'; message: string }
|
| { type: 'progress'; message: string }
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "2.1.16",
|
"version": "2.1.17",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="195k tokens, 98% of context window">
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="196k tokens, 98% of context window">
|
||||||
<title>195k tokens, 98% of context window</title>
|
<title>196k tokens, 98% of context window</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" stop-opacity=".1"/>
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||||
<text x="26" y="14">tokens</text>
|
<text x="26" y="14">tokens</text>
|
||||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">195k</text>
|
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">196k</text>
|
||||||
<text x="71" y="14">195k</text>
|
<text x="71" y="14">196k</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Reference in New Issue
Block a user