Compare commits

..
Author SHA1 Message Date
Peter Steinberger 667bee0de0 docs: add Bedrock EC2 role notes (#1625) (thanks @sergical) 2026-01-24 21:16:59 +00:00
Sergiy DybskiyandClaude Opus 4.5 cd48aac32b docs: add EC2 instance role setup for Bedrock
- Add EC2 Instance Roles section with workaround for IMDS credential detection
- Include step-by-step IAM role and instance profile setup
- Document required permissions (bedrock:InvokeModel, ListFoundationModels)
- Update example model to Claude Opus 4.5 (latest)

The AWS SDK auto-detects EC2 instance roles via IMDS, but Clawdbot's
credential detection only checks environment variables. The workaround
is to set AWS_PROFILE=default to signal credentials are available.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 21:13:13 +00:00
28 changed files with 468 additions and 1216 deletions

No files matched your search

-4
View File
@@ -10,15 +10,11 @@ Docs: https://docs.clawd.bot
- Docs: update Fly.io guide notes.
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Models: add Ollama provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama
### Fixes
- Web UI: hide internal `message_id` hints in chat bubbles.
- Heartbeat: normalize target identifiers for consistent routing.
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery.
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
-24
View File
@@ -236,30 +236,6 @@ MiniMax is configured via `models.providers` because it uses custom endpoints:
See [/providers/minimax](/providers/minimax) for setup details, model options, and config snippets.
### Ollama
Ollama is a local LLM runtime that provides an OpenAI-compatible API:
- Provider: `ollama`
- Auth: `OLLAMA_API_KEY` (any value; Ollama runs locally)
- Example model: `ollama/llama3.3`
- Installation: https://ollama.ai
```bash
# Install Ollama, then pull a model:
ollama pull llama3.3
```
```json5
{
agents: {
defaults: { model: { primary: "ollama/llama3.3" } }
}
}
```
Ollama is auto-discovered when `OLLAMA_API_KEY` (or an auth profile) is set and no explicit `models.providers.ollama` entry exists. Discovery probes `http://127.0.0.1:11434` and filters to tool-capable models. See [/providers/ollama](/providers/ollama) for model recommendations and custom configuration.
### Local proxies (LM Studio, vLLM, LiteLLM, etc.)
Example (OpenAI‑compatible):
-1
View File
@@ -35,7 +35,6 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)
- [Ollama (local models)](/providers/ollama)
## Transcription providers
-171
View File
@@ -1,171 +0,0 @@
---
summary: "Run Clawdbot with Ollama (local LLM runtime)"
read_when:
- You want to run Clawdbot with local models via Ollama
- You need Ollama setup and configuration guidance
---
# Ollama
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. Clawdbot integrates with Ollama's OpenAI-compatible API and can **auto-discover tool-capable models** when enabled via `OLLAMA_API_KEY` (or an auth profile) and no explicit `models.providers.ollama` config is set.
## Quick start
1) Install Ollama: https://ollama.ai
2) Pull a model:
```bash
ollama pull llama3.3
# or
ollama pull qwen2.5-coder:32b
# or
ollama pull deepseek-r1:32b
```
3) Enable Ollama for Clawdbot (any value works; Ollama doesn't require a real key):
```bash
# Set environment variable
export OLLAMA_API_KEY="ollama-local"
# Or configure in your config file
clawdbot config set models.providers.ollama.apiKey "ollama-local"
```
4) Use Ollama models:
```json5
{
agents: {
defaults: {
model: { primary: "ollama/llama3.3" }
}
}
}
```
## Model Discovery
When Ollama is enabled via `OLLAMA_API_KEY` (or an auth profile) and no explicit `models.providers.ollama` entry exists, Clawdbot automatically detects models installed on your Ollama instance by querying `/api/tags` and `/api/show` at `http://localhost:11434`. It only keeps models that report tool support, so you don't need to manually configure them.
To see what models are available:
```bash
ollama list
clawdbot models list
```
To add a new model, simply pull it with Ollama:
```bash
ollama pull mistral
```
The new model will be automatically discovered and available to use.
If you set `models.providers.ollama` explicitly, auto-discovery is skipped. Define your models manually in that case.
## Configuration
### Basic Setup
The simplest way to enable Ollama is via environment variable:
```bash
export OLLAMA_API_KEY="ollama-local"
```
### Custom Base URL
If Ollama is running on a different host or port (note: explicit config skips auto-discovery, so define models manually):
```json5
{
models: {
providers: {
ollama: {
apiKey: "ollama-local",
baseUrl: "http://192.168.1.100:11434/v1"
}
}
}
}
```
### Model Selection
Once configured, all your Ollama models are available:
```json5
{
agents: {
defaults: {
model: {
primary: "ollama/llama3.3",
fallback: ["ollama/qwen2.5-coder:32b"]
}
}
}
}
```
## Advanced
### Reasoning Models
Models with "r1" or "reasoning" in their name are automatically detected as reasoning models and will use extended thinking features:
```bash
ollama pull deepseek-r1:32b
```
### Model Costs
Ollama is free and runs locally, so all model costs are set to $0.
### Context Windows
Ollama models use default context windows. You can customize these in your provider configuration if needed.
## Troubleshooting
### Ollama not detected
Make sure Ollama is running:
```bash
ollama serve
```
And that the API is accessible:
```bash
curl http://localhost:11434/api/tags
```
### No models available
Pull at least one model:
```bash
ollama list # See what's installed
ollama pull llama3.3 # Pull a model
```
### Connection refused
Check that Ollama is running on the correct port:
```bash
# Check if Ollama is running
ps aux | grep ollama
# Or restart Ollama
ollama serve
```
## See Also
- [Model Providers](/concepts/model-providers) - Overview of all providers
- [Model Selection](/agents/model-selection) - How to choose models
- [Configuration](/configuration) - Full config reference
-1
View File
@@ -284,7 +284,6 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
synthetic: "SYNTHETIC_API_KEY",
mistral: "MISTRAL_API_KEY",
opencode: "OPENCODE_API_KEY",
ollama: "OLLAMA_API_KEY",
};
const envVar = envMap[normalized];
if (!envVar) return null;
@@ -1,106 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveImplicitProviders } from "./models-config.providers.js";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
describe("Ollama provider", () => {
const previousEnv = { ...process.env };
afterEach(() => {
for (const key of Object.keys(process.env)) {
if (!(key in previousEnv)) delete process.env[key];
}
for (const [key, value] of Object.entries(previousEnv)) {
process.env[key] = value;
}
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("should not include ollama when no API key is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-"));
const providers = await resolveImplicitProviders({ agentDir });
// Ollama requires explicit configuration via OLLAMA_API_KEY env var or profile
expect(providers?.ollama).toBeUndefined();
});
it("discovers tool-capable models when OLLAMA_API_KEY is set", async () => {
process.env.OLLAMA_API_KEY = "ollama-local";
delete process.env.VITEST;
process.env.NODE_ENV = "development";
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
models: [{ name: "llama3.3" }, { name: "no-tools-model" }],
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
capabilities: ["tools", "thinking"],
model_info: {
"general.architecture": "llama",
"llama.context_length": "4096",
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
capabilities: ["thinking"],
model_info: {
"general.architecture": "llama",
"llama.context_length": "2048",
},
}),
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-"));
const providers = await resolveImplicitProviders({ agentDir });
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls[0]?.[0]).toBe("http://127.0.0.1:11434/api/tags");
expect(fetchMock.mock.calls[1]?.[0]).toBe("http://127.0.0.1:11434/api/show");
const provider = providers?.ollama;
expect(provider?.baseUrl).toBe("http://127.0.0.1:11434/v1");
expect(provider?.models).toHaveLength(1);
expect(provider?.models?.[0]?.id).toBe("llama3.3");
expect(provider?.models?.[0]?.reasoning).toBe(true);
expect(provider?.models?.[0]?.contextWindow).toBe(4096);
expect(provider?.models?.[0]?.maxTokens).toBe(4096 * 10);
});
it("skips discovery when ollama is explicitly configured", async () => {
process.env.OLLAMA_API_KEY = "ollama-local";
delete process.env.VITEST;
process.env.NODE_ENV = "development";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-"));
const providers = await resolveImplicitProviders({
agentDir,
explicitProviders: {
ollama: {
baseUrl: "http://example.com/v1",
api: "openai-completions",
models: [],
},
},
});
expect(fetchMock).not.toHaveBeenCalled();
expect(providers?.ollama).toBeUndefined();
});
});
+1 -149
View File
@@ -1,11 +1,9 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { ModelDefinitionConfig } from "../config/types.models.js";
import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { normalizeProviderId } from "./model-selection.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
import { discoverBedrockModels } from "./bedrock-discovery.js";
import {
@@ -64,127 +62,6 @@ const QWEN_PORTAL_DEFAULT_COST = {
cacheWrite: 0,
};
const OLLAMA_HOST_BASE_URL = "http://127.0.0.1:11434";
const OLLAMA_DEFAULT_CONTEXT_WINDOW = 8192;
const OLLAMA_MAX_TOKENS_MULTIPLIER = 10;
const OLLAMA_DISCOVERY_TIMEOUT_MS = 5000;
const OLLAMA_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
interface OllamaModel {
name: string;
modified_at: string;
size: number;
digest: string;
details?: {
family?: string;
parameter_size?: string;
};
}
interface OllamaTagsResponse {
models: OllamaModel[];
}
interface OllamaShowResponse {
capabilities?: string[];
model_info?: Record<string, string | number>;
}
function parseOllamaNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function resolveOllamaContextWindow(
modelInfo: Record<string, string | number> | undefined,
): number {
if (!modelInfo) return OLLAMA_DEFAULT_CONTEXT_WINDOW;
const architecture = String(modelInfo["general.architecture"] ?? "").trim();
const contextKey = architecture ? `${architecture}.context_length` : "";
const contextWindow =
(contextKey ? parseOllamaNumber(modelInfo[contextKey]) : undefined) ??
parseOllamaNumber(modelInfo["context_length"]);
return contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW;
}
function normalizeOllamaHostBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, "");
return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed;
}
async function discoverOllamaModels(baseUrl: string): Promise<ModelDefinitionConfig[]> {
// Skip Ollama discovery in test environments
if (process.env.VITEST || process.env.NODE_ENV === "test") {
return [];
}
try {
const response = await fetch(`${baseUrl}/api/tags`, {
signal: AbortSignal.timeout(OLLAMA_DISCOVERY_TIMEOUT_MS),
});
if (!response.ok) {
console.warn(`Failed to discover Ollama models: ${response.status}`);
return [];
}
const data = (await response.json()) as OllamaTagsResponse;
if (!data.models || data.models.length === 0) {
console.warn("No Ollama models found on local instance");
return [];
}
const models = await Promise.all(
data.models.map(async (model) => {
try {
const detailsResponse = await fetch(`${baseUrl}/api/show`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: model.name }),
signal: AbortSignal.timeout(OLLAMA_DISCOVERY_TIMEOUT_MS),
});
if (!detailsResponse.ok) {
console.warn(
`Failed to fetch Ollama model details for ${model.name}: ${detailsResponse.status}`,
);
return null;
}
const details = (await detailsResponse.json()) as OllamaShowResponse;
const capabilities = Array.isArray(details.capabilities) ? details.capabilities : [];
if (!capabilities.includes("tools")) {
console.debug(`Skipping Ollama model ${model.name}: does not support tools`);
return null;
}
const contextWindow = resolveOllamaContextWindow(details.model_info);
return {
id: model.name,
name: model.name,
reasoning: capabilities.includes("thinking"),
input: ["text"],
cost: OLLAMA_DEFAULT_COST,
contextWindow,
maxTokens: contextWindow * OLLAMA_MAX_TOKENS_MULTIPLIER,
};
} catch (error) {
console.warn(`Failed to fetch Ollama model details for ${model.name}: ${String(error)}`);
return null;
}
}),
);
return models.filter((model): model is ModelDefinitionConfig => Boolean(model));
} catch (error) {
console.warn(`Failed to discover Ollama models: ${String(error)}`);
return [];
}
}
function normalizeApiKeyConfig(value: string): string {
const trimmed = value.trim();
const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed);
@@ -398,28 +275,11 @@ function buildSyntheticProvider(): ProviderConfig {
};
}
async function buildOllamaProvider(baseUrl: string): Promise<ProviderConfig> {
const hostBaseUrl = normalizeOllamaHostBaseUrl(baseUrl);
const models = await discoverOllamaModels(hostBaseUrl);
return {
baseUrl: `${hostBaseUrl}/v1`,
api: "openai-completions",
models,
};
}
export async function resolveImplicitProviders(params: {
agentDir: string;
explicitProviders?: ModelsConfig["providers"];
}): Promise<ModelsConfig["providers"]> {
export function resolveImplicitProviders(params: { agentDir: string }): ModelsConfig["providers"] {
const providers: Record<string, ProviderConfig> = {};
const authStore = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const explicitProviders = params.explicitProviders ?? {};
const hasExplicitOllama = Object.keys(explicitProviders).some(
(key) => normalizeProviderId(key) === "ollama",
);
const minimaxKey =
resolveEnvApiKeyVarName("minimax") ??
@@ -457,14 +317,6 @@ export async function resolveImplicitProviders(params: {
};
}
// Ollama provider - only add if explicitly configured
const ollamaKey =
resolveEnvApiKeyVarName("ollama") ??
resolveApiKeyFromProfiles({ provider: "ollama", store: authStore });
if (ollamaKey && !hasExplicitOllama) {
providers.ollama = { ...(await buildOllamaProvider(OLLAMA_HOST_BASE_URL)), apiKey: ollamaKey };
}
return providers;
}
+1 -1
View File
@@ -80,7 +80,7 @@ export async function ensureClawdbotModelsJson(
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveClawdbotAgentDir();
const explicitProviders = (cfg.models?.providers ?? {}) as Record<string, ProviderConfig>;
const implicitProviders = await resolveImplicitProviders({ agentDir, explicitProviders });
const implicitProviders = resolveImplicitProviders({ agentDir });
const providers: Record<string, ProviderConfig> = mergeProviders({
implicit: implicitProviders,
explicit: explicitProviders,
@@ -1,7 +1,15 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { formatAssistantErrorText } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
missing: false,
...overrides,
});
describe("formatAssistantErrorText", () => {
const makeAssistantError = (errorMessage: string): AssistantMessage =>
({
@@ -13,16 +21,6 @@ describe("formatAssistantErrorText", () => {
const msg = makeAssistantError("request_too_large");
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
});
it("returns context overflow for Anthropic 'Request size exceeds model context window'", () => {
// This is the new Anthropic error format that wasn't being detected.
// Without the fix, this falls through to the invalidRequest regex and returns
// "LLM request rejected: Request size exceeds model context window"
// instead of the context overflow message, preventing auto-compaction.
const msg = makeAssistantError(
'{"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}',
);
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
});
it("returns a friendly message for Anthropic role ordering", () => {
const msg = makeAssistantError('messages: roles must alternate between "user" and "assistant"');
expect(formatAssistantErrorText(msg)).toContain("Message ordering conflict");
@@ -1,6 +1,14 @@
import { describe, expect, it } from "vitest";
import { isContextOverflowError } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
missing: false,
...overrides,
});
describe("isContextOverflowError", () => {
it("matches known overflow hints", () => {
const samples = [
@@ -16,34 +24,7 @@ describe("isContextOverflowError", () => {
expect(isContextOverflowError(sample)).toBe(true);
}
});
it("matches Anthropic 'Request size exceeds model context window' error", () => {
// Anthropic returns this error format when the prompt exceeds the context window.
// Without this fix, auto-compaction is NOT triggered because neither
// isContextOverflowError nor pi-ai's isContextOverflow recognizes this pattern.
// The user sees: "LLM request rejected: Request size exceeds model context window"
// instead of automatic compaction + retry.
const anthropicRawError =
'{"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}';
expect(isContextOverflowError(anthropicRawError)).toBe(true);
});
it("matches 'exceeds model context window' in various formats", () => {
const samples = [
"Request size exceeds model context window",
"request size exceeds model context window",
'400 {"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}',
"The request size exceeds model context window limit",
];
for (const sample of samples) {
expect(isContextOverflowError(sample)).toBe(true);
}
});
it("ignores unrelated errors", () => {
expect(isContextOverflowError("rate limit exceeded")).toBe(false);
expect(isContextOverflowError("request size exceeds upload limit")).toBe(false);
expect(isContextOverflowError("model not found")).toBe(false);
expect(isContextOverflowError("authentication failed")).toBe(false);
});
});
-7
View File
@@ -7,19 +7,12 @@ import type { FailoverReason } from "./types.js";
export function isContextOverflowError(errorMessage?: string): boolean {
if (!errorMessage) return false;
const lower = errorMessage.toLowerCase();
const hasRequestSizeExceeds = lower.includes("request size exceeds");
const hasContextWindow =
lower.includes("context window") ||
lower.includes("context length") ||
lower.includes("maximum context length");
return (
lower.includes("request_too_large") ||
lower.includes("request exceeds the maximum size") ||
lower.includes("context length exceeded") ||
lower.includes("maximum context length") ||
lower.includes("prompt is too long") ||
lower.includes("exceeds model context window") ||
(hasRequestSizeExceeds && hasContextWindow) ||
lower.includes("context overflow") ||
(lower.includes("413") && lower.includes("too large"))
);
@@ -72,7 +72,7 @@ const _makeOpenAiConfig = (modelIds: string[]) =>
}) satisfies ClawdbotConfig;
const _ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir) as unknown;
ensureClawdbotModelsJson(cfg, agentDir);
const _textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
@@ -71,7 +71,7 @@ const _makeOpenAiConfig = (modelIds: string[]) =>
}) satisfies ClawdbotConfig;
const _ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir) as unknown;
ensureClawdbotModelsJson(cfg, agentDir);
const _textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
@@ -70,7 +70,7 @@ const _makeOpenAiConfig = (modelIds: string[]) =>
}) satisfies ClawdbotConfig;
const _ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir) as unknown;
ensureClawdbotModelsJson(cfg, agentDir);
const _textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
@@ -70,7 +70,7 @@ const _makeOpenAiConfig = (modelIds: string[]) =>
}) satisfies ClawdbotConfig;
const _ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir) as unknown;
ensureClawdbotModelsJson(cfg, agentDir);
const _textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
@@ -71,7 +71,7 @@ const _makeOpenAiConfig = (modelIds: string[]) =>
}) satisfies ClawdbotConfig;
const _ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir) as unknown;
ensureClawdbotModelsJson(cfg, agentDir);
const _textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
@@ -70,7 +70,7 @@ const _makeOpenAiConfig = (modelIds: string[]) =>
}) satisfies ClawdbotConfig;
const _ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir) as unknown;
ensureClawdbotModelsJson(cfg, agentDir);
const _textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
@@ -71,7 +71,7 @@ const _makeOpenAiConfig = (modelIds: string[]) =>
}) satisfies ClawdbotConfig;
const _ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir) as unknown;
ensureClawdbotModelsJson(cfg, agentDir);
const _textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
+1 -1
View File
@@ -130,7 +130,7 @@ const makeOpenAiConfig = (modelIds: string[]) =>
},
}) satisfies ClawdbotConfig;
const ensureModels = (cfg: ClawdbotConfig) => ensureClawdbotModelsJson(cfg, agentDir) as unknown;
const ensureModels = (cfg: ClawdbotConfig) => ensureClawdbotModelsJson(cfg, agentDir);
const nextSessionFile = () => {
sessionCounter += 1;
+345 -358
View File
@@ -68,13 +68,12 @@ import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../d
import { describeUnknownError, mapThinkingLevel, resolveExecToolDefaults } from "./utils.js";
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
export type CompactEmbeddedPiSessionParams = {
export async function compactEmbeddedPiSession(params: {
sessionId: string;
sessionKey?: string;
messageChannel?: string;
messageProvider?: string;
agentAccountId?: string;
authProfileId?: string;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
@@ -98,366 +97,354 @@ export type CompactEmbeddedPiSessionParams = {
enqueue?: typeof enqueueCommand;
extraSystemPrompt?: string;
ownerNumbers?: string[];
};
/**
* Core compaction logic without lane queueing.
* Use this when already inside a session/global lane to avoid deadlocks.
*/
export async function compactEmbeddedPiSessionDirect(
params: CompactEmbeddedPiSessionParams,
): Promise<EmbeddedPiCompactResult> {
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const prevCwd = process.cwd();
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
await ensureClawdbotModelsJson(params.config, agentDir);
const { model, error, authStorage, modelRegistry } = resolveModel(
provider,
modelId,
agentDir,
params.config,
);
if (!model) {
return {
ok: false,
compacted: false,
reason: error ?? `Unknown model: ${provider}/${modelId}`,
};
}
try {
const apiKeyInfo = await getApiKeyForModel({
model,
cfg: params.config,
profileId: params.authProfileId,
agentDir,
});
if (!apiKeyInfo.apiKey) {
if (apiKeyInfo.mode !== "aws-sdk") {
throw new Error(
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
);
}
} else if (model.provider === "github-copilot") {
const { resolveCopilotApiToken } = await import("../../providers/github-copilot-token.js");
const copilotToken = await resolveCopilotApiToken({
githubToken: apiKeyInfo.apiKey,
});
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
} else {
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
}
} catch (err) {
return {
ok: false,
compacted: false,
reason: describeUnknownError(err),
};
}
await fs.mkdir(resolvedWorkspace, { recursive: true });
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
const effectiveWorkspace = sandbox?.enabled
? sandbox.workspaceAccess === "rw"
? resolvedWorkspace
: sandbox.workspaceDir
: resolvedWorkspace;
await fs.mkdir(effectiveWorkspace, { recursive: true });
await ensureSessionHeader({
sessionFile: params.sessionFile,
sessionId: params.sessionId,
cwd: effectiveWorkspace,
});
let restoreSkillEnv: (() => void) | undefined;
process.chdir(effectiveWorkspace);
try {
const shouldLoadSkillEntries = !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
const skillEntries = shouldLoadSkillEntries
? loadWorkspaceSkillEntries(effectiveWorkspace)
: [];
restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
config: params.config,
})
: applySkillEnvOverrides({
skills: skillEntries ?? [],
config: params.config,
});
const skillsPrompt = resolveSkillsPromptForRun({
skillsSnapshot: params.skillsSnapshot,
entries: shouldLoadSkillEntries ? skillEntries : undefined,
config: params.config,
workspaceDir: effectiveWorkspace,
});
const sessionLabel = params.sessionKey ?? params.sessionId;
const { contextFiles } = await resolveBootstrapContextForRun({
workspaceDir: effectiveWorkspace,
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
});
const runAbortController = new AbortController();
const toolsRaw = createClawdbotCodingTools({
exec: {
...resolveExecToolDefaults(params.config),
elevated: params.bashElevated,
},
sandbox,
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
sessionKey: params.sessionKey ?? params.sessionId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
agentDir,
workspaceDir: effectiveWorkspace,
config: params.config,
abortSignal: runAbortController.signal,
modelProvider: model.provider,
modelId,
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
});
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider });
logToolSchemasForGoogle({ tools, provider });
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
let runtimeCapabilities = runtimeChannel
? (resolveChannelCapabilities({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
}) ?? [])
: undefined;
if (runtimeChannel === "telegram" && params.config) {
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
if (inlineButtonsScope !== "off") {
if (!runtimeCapabilities) runtimeCapabilities = [];
if (
!runtimeCapabilities.some((cap) => String(cap).trim().toLowerCase() === "inlinebuttons")
) {
runtimeCapabilities.push("inlineButtons");
}
}
}
// Resolve channel-specific message actions for system prompt
const channelActions = runtimeChannel
? listChannelSupportedActions({
cfg: params.config,
channel: runtimeChannel,
})
: undefined;
const messageToolHints = runtimeChannel
? resolveChannelMessageToolHints({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
})
: undefined;
const runtimeInfo = {
host: machineName,
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: `${provider}/${modelId}`,
channel: runtimeChannel,
capabilities: runtimeCapabilities,
channelActions,
};
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(provider);
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const docsPath = await resolveClawdbotDocsPath({
workspaceDir: effectiveWorkspace,
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel,
reasoningLevel: params.reasoningLevel ?? "off",
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
heartbeatPrompt: isDefaultAgent
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined,
skillsPrompt,
docsPath: docsPath ?? undefined,
ttsHint,
promptMode,
runtimeInfo,
messageToolHints,
sandboxInfo,
tools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
userTimeFormat,
contextFiles,
});
const systemPrompt = createSystemPromptOverride(appendPrompt);
const sessionLock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
});
try {
await prewarmSessionFile(params.sessionFile);
const transcriptPolicy = resolveTranscriptPolicy({
modelApi: model.api,
provider,
modelId,
});
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
agentId: sessionAgentId,
sessionKey: params.sessionKey,
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
});
trackSessionManagerAccess(params.sessionFile);
const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir);
ensurePiCompactionReserveTokens({
settingsManager,
minReserveTokens: resolveCompactionReserveTokensFloor(params.config),
});
const additionalExtensionPaths = buildEmbeddedExtensionPaths({
cfg: params.config,
sessionManager,
provider,
modelId,
model,
});
const { builtInTools, customTools } = splitSdkTools({
tools,
sandboxEnabled: !!sandbox?.enabled,
});
let session: Awaited<ReturnType<typeof createAgentSession>>["session"];
({ session } = await createAgentSession({
cwd: resolvedWorkspace,
agentDir,
authStorage,
modelRegistry,
model,
thinkingLevel: mapThinkingLevel(params.thinkLevel),
systemPrompt,
tools: builtInTools,
customTools,
sessionManager,
settingsManager,
skills: [],
contextFiles: [],
additionalExtensionPaths,
}));
try {
const prior = await sanitizeSessionHistory({
messages: session.messages,
modelApi: model.api,
modelId,
provider,
sessionManager,
sessionId: params.sessionId,
policy: transcriptPolicy,
});
const validatedGemini = transcriptPolicy.validateGeminiTurns
? validateGeminiTurns(prior)
: prior;
const validated = transcriptPolicy.validateAnthropicTurns
? validateAnthropicTurns(validatedGemini)
: validatedGemini;
const limited = limitHistoryTurns(
validated,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
);
if (limited.length > 0) {
session.agent.replaceMessages(limited);
}
const result = await session.compact(params.customInstructions);
// Estimate tokens after compaction by summing token estimates for remaining messages
let tokensAfter: number | undefined;
try {
tokensAfter = 0;
for (const message of session.messages) {
tokensAfter += estimateTokens(message);
}
// Sanity check: tokensAfter should be less than tokensBefore
if (tokensAfter > result.tokensBefore) {
tokensAfter = undefined; // Don't trust the estimate
}
} catch {
// If estimation fails, leave tokensAfter undefined
tokensAfter = undefined;
}
return {
ok: true,
compacted: true,
result: {
summary: result.summary,
firstKeptEntryId: result.firstKeptEntryId,
tokensBefore: result.tokensBefore,
tokensAfter,
details: result.details,
},
};
} finally {
sessionManager.flushPendingToolResults?.();
session.dispose();
}
} finally {
await sessionLock.release();
}
} catch (err) {
return {
ok: false,
compacted: false,
reason: describeUnknownError(err),
};
} finally {
restoreSkillEnv?.();
process.chdir(prevCwd);
}
}
/**
* Compacts a session with lane queueing (session lane + global lane).
* Use this from outside a lane context. If already inside a lane, use
* `compactEmbeddedPiSessionDirect` to avoid deadlocks.
*/
export async function compactEmbeddedPiSession(
params: CompactEmbeddedPiSessionParams,
): Promise<EmbeddedPiCompactResult> {
}): Promise<EmbeddedPiCompactResult> {
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
const globalLane = resolveGlobalLane(params.lane);
const enqueueGlobal =
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
return enqueueCommandInLane(sessionLane, () =>
enqueueGlobal(async () => compactEmbeddedPiSessionDirect(params)),
enqueueGlobal(async () => {
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const prevCwd = process.cwd();
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
await ensureClawdbotModelsJson(params.config, agentDir);
const { model, error, authStorage, modelRegistry } = resolveModel(
provider,
modelId,
agentDir,
params.config,
);
if (!model) {
return {
ok: false,
compacted: false,
reason: error ?? `Unknown model: ${provider}/${modelId}`,
};
}
try {
const apiKeyInfo = await getApiKeyForModel({
model,
cfg: params.config,
agentDir,
});
if (!apiKeyInfo.apiKey) {
if (apiKeyInfo.mode !== "aws-sdk") {
throw new Error(
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
);
}
} else if (model.provider === "github-copilot") {
const { resolveCopilotApiToken } =
await import("../../providers/github-copilot-token.js");
const copilotToken = await resolveCopilotApiToken({
githubToken: apiKeyInfo.apiKey,
});
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
} else {
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
}
} catch (err) {
return {
ok: false,
compacted: false,
reason: describeUnknownError(err),
};
}
await fs.mkdir(resolvedWorkspace, { recursive: true });
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
const effectiveWorkspace = sandbox?.enabled
? sandbox.workspaceAccess === "rw"
? resolvedWorkspace
: sandbox.workspaceDir
: resolvedWorkspace;
await fs.mkdir(effectiveWorkspace, { recursive: true });
await ensureSessionHeader({
sessionFile: params.sessionFile,
sessionId: params.sessionId,
cwd: effectiveWorkspace,
});
let restoreSkillEnv: (() => void) | undefined;
process.chdir(effectiveWorkspace);
try {
const shouldLoadSkillEntries =
!params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
const skillEntries = shouldLoadSkillEntries
? loadWorkspaceSkillEntries(effectiveWorkspace)
: [];
restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
config: params.config,
})
: applySkillEnvOverrides({
skills: skillEntries ?? [],
config: params.config,
});
const skillsPrompt = resolveSkillsPromptForRun({
skillsSnapshot: params.skillsSnapshot,
entries: shouldLoadSkillEntries ? skillEntries : undefined,
config: params.config,
workspaceDir: effectiveWorkspace,
});
const sessionLabel = params.sessionKey ?? params.sessionId;
const { contextFiles } = await resolveBootstrapContextForRun({
workspaceDir: effectiveWorkspace,
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
});
const runAbortController = new AbortController();
const toolsRaw = createClawdbotCodingTools({
exec: {
...resolveExecToolDefaults(params.config),
elevated: params.bashElevated,
},
sandbox,
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
sessionKey: params.sessionKey ?? params.sessionId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
agentDir,
workspaceDir: effectiveWorkspace,
config: params.config,
abortSignal: runAbortController.signal,
modelProvider: model.provider,
modelId,
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
});
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider });
logToolSchemasForGoogle({ tools, provider });
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(
params.messageChannel ?? params.messageProvider,
);
let runtimeCapabilities = runtimeChannel
? (resolveChannelCapabilities({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
}) ?? [])
: undefined;
if (runtimeChannel === "telegram" && params.config) {
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
if (inlineButtonsScope !== "off") {
if (!runtimeCapabilities) runtimeCapabilities = [];
if (
!runtimeCapabilities.some(
(cap) => String(cap).trim().toLowerCase() === "inlinebuttons",
)
) {
runtimeCapabilities.push("inlineButtons");
}
}
}
// Resolve channel-specific message actions for system prompt
const channelActions = runtimeChannel
? listChannelSupportedActions({
cfg: params.config,
channel: runtimeChannel,
})
: undefined;
const messageToolHints = runtimeChannel
? resolveChannelMessageToolHints({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
})
: undefined;
const runtimeInfo = {
host: machineName,
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: `${provider}/${modelId}`,
channel: runtimeChannel,
capabilities: runtimeCapabilities,
channelActions,
};
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(provider);
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const docsPath = await resolveClawdbotDocsPath({
workspaceDir: effectiveWorkspace,
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel,
reasoningLevel: params.reasoningLevel ?? "off",
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
heartbeatPrompt: isDefaultAgent
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined,
skillsPrompt,
docsPath: docsPath ?? undefined,
ttsHint,
promptMode,
runtimeInfo,
messageToolHints,
sandboxInfo,
tools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
userTimeFormat,
contextFiles,
});
const systemPrompt = createSystemPromptOverride(appendPrompt);
const sessionLock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
});
try {
await prewarmSessionFile(params.sessionFile);
const transcriptPolicy = resolveTranscriptPolicy({
modelApi: model.api,
provider,
modelId,
});
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
agentId: sessionAgentId,
sessionKey: params.sessionKey,
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
});
trackSessionManagerAccess(params.sessionFile);
const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir);
ensurePiCompactionReserveTokens({
settingsManager,
minReserveTokens: resolveCompactionReserveTokensFloor(params.config),
});
const additionalExtensionPaths = buildEmbeddedExtensionPaths({
cfg: params.config,
sessionManager,
provider,
modelId,
model,
});
const { builtInTools, customTools } = splitSdkTools({
tools,
sandboxEnabled: !!sandbox?.enabled,
});
let session: Awaited<ReturnType<typeof createAgentSession>>["session"];
({ session } = await createAgentSession({
cwd: resolvedWorkspace,
agentDir,
authStorage,
modelRegistry,
model,
thinkingLevel: mapThinkingLevel(params.thinkLevel),
systemPrompt,
tools: builtInTools,
customTools,
sessionManager,
settingsManager,
skills: [],
contextFiles: [],
additionalExtensionPaths,
}));
try {
const prior = await sanitizeSessionHistory({
messages: session.messages,
modelApi: model.api,
modelId,
provider,
sessionManager,
sessionId: params.sessionId,
policy: transcriptPolicy,
});
const validatedGemini = transcriptPolicy.validateGeminiTurns
? validateGeminiTurns(prior)
: prior;
const validated = transcriptPolicy.validateAnthropicTurns
? validateAnthropicTurns(validatedGemini)
: validatedGemini;
const limited = limitHistoryTurns(
validated,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
);
if (limited.length > 0) {
session.agent.replaceMessages(limited);
}
const result = await session.compact(params.customInstructions);
// Estimate tokens after compaction by summing token estimates for remaining messages
let tokensAfter: number | undefined;
try {
tokensAfter = 0;
for (const message of session.messages) {
tokensAfter += estimateTokens(message);
}
// Sanity check: tokensAfter should be less than tokensBefore
if (tokensAfter > result.tokensBefore) {
tokensAfter = undefined; // Don't trust the estimate
}
} catch {
// If estimation fails, leave tokensAfter undefined
tokensAfter = undefined;
}
return {
ok: true,
compacted: true,
result: {
summary: result.summary,
firstKeptEntryId: result.firstKeptEntryId,
tokensBefore: result.tokensBefore,
tokensAfter,
details: result.details,
},
};
} finally {
sessionManager.flushPendingToolResults?.();
session.dispose();
}
} finally {
await sessionLock.release();
}
} catch (err) {
return {
ok: false,
compacted: false,
reason: describeUnknownError(err),
};
} finally {
restoreSkillEnv?.();
process.chdir(prevCwd);
}
}),
);
}
@@ -1,285 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("./run/attempt.js", () => ({
runEmbeddedAttempt: vi.fn(),
}));
vi.mock("./compact.js", () => ({
compactEmbeddedPiSessionDirect: vi.fn(),
}));
vi.mock("./model.js", () => ({
resolveModel: vi.fn(() => ({
model: {
id: "test-model",
provider: "anthropic",
contextWindow: 200000,
api: "messages",
},
error: null,
authStorage: {
setRuntimeApiKey: vi.fn(),
},
modelRegistry: {},
})),
}));
vi.mock("../model-auth.js", () => ({
ensureAuthProfileStore: vi.fn(() => ({})),
getApiKeyForModel: vi.fn(async () => ({
apiKey: "test-key",
profileId: "test-profile",
source: "test",
})),
resolveAuthProfileOrder: vi.fn(() => []),
}));
vi.mock("../models-config.js", () => ({
ensureClawdbotModelsJson: vi.fn(async () => {}),
}));
vi.mock("../context-window-guard.js", () => ({
CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000,
CONTEXT_WINDOW_WARN_BELOW_TOKENS: 5000,
evaluateContextWindowGuard: vi.fn(() => ({
shouldWarn: false,
shouldBlock: false,
tokens: 200000,
source: "model",
})),
resolveContextWindowInfo: vi.fn(() => ({
tokens: 200000,
source: "model",
})),
}));
vi.mock("../../process/command-queue.js", () => ({
enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()),
}));
vi.mock("../../utils.js", () => ({
resolveUserPath: vi.fn((p: string) => p),
}));
vi.mock("../../utils/message-channel.js", () => ({
isMarkdownCapableMessageChannel: vi.fn(() => true),
}));
vi.mock("../agent-paths.js", () => ({
resolveClawdbotAgentDir: vi.fn(() => "/tmp/agent-dir"),
}));
vi.mock("../auth-profiles.js", () => ({
markAuthProfileFailure: vi.fn(async () => {}),
markAuthProfileGood: vi.fn(async () => {}),
markAuthProfileUsed: vi.fn(async () => {}),
}));
vi.mock("../defaults.js", () => ({
DEFAULT_CONTEXT_TOKENS: 200000,
DEFAULT_MODEL: "test-model",
DEFAULT_PROVIDER: "anthropic",
}));
vi.mock("../failover-error.js", () => ({
FailoverError: class extends Error {
constructor(msg: string) {
super(msg);
}
},
resolveFailoverStatus: vi.fn(),
}));
vi.mock("../usage.js", () => ({
normalizeUsage: vi.fn(() => undefined),
}));
vi.mock("./lanes.js", () => ({
resolveSessionLane: vi.fn(() => "session-lane"),
resolveGlobalLane: vi.fn(() => "global-lane"),
}));
vi.mock("./logger.js", () => ({
log: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("./run/payloads.js", () => ({
buildEmbeddedRunPayloads: vi.fn(() => []),
}));
vi.mock("./utils.js", () => ({
describeUnknownError: vi.fn((err: unknown) => {
if (err instanceof Error) return err.message;
return String(err);
}),
}));
vi.mock("../pi-embedded-helpers.js", async () => {
return {
isCompactionFailureError: (msg?: string) => {
if (!msg) return false;
const lower = msg.toLowerCase();
return lower.includes("request_too_large") && lower.includes("summarization failed");
},
isContextOverflowError: (msg?: string) => {
if (!msg) return false;
const lower = msg.toLowerCase();
return lower.includes("request_too_large") || lower.includes("request size exceeds");
},
isFailoverAssistantError: vi.fn(() => false),
isFailoverErrorMessage: vi.fn(() => false),
isAuthAssistantError: vi.fn(() => false),
isRateLimitAssistantError: vi.fn(() => false),
classifyFailoverReason: vi.fn(() => null),
formatAssistantErrorText: vi.fn(() => ""),
pickFallbackThinkingLevel: vi.fn(() => null),
isTimeoutErrorMessage: vi.fn(() => false),
parseImageDimensionError: vi.fn(() => null),
};
});
import { runEmbeddedPiAgent } from "./run.js";
import { runEmbeddedAttempt } from "./run/attempt.js";
import { compactEmbeddedPiSessionDirect } from "./compact.js";
import { log } from "./logger.js";
import type { EmbeddedRunAttemptResult } from "./run/types.js";
const mockedRunEmbeddedAttempt = vi.mocked(runEmbeddedAttempt);
const mockedCompactDirect = vi.mocked(compactEmbeddedPiSessionDirect);
function makeAttemptResult(
overrides: Partial<EmbeddedRunAttemptResult> = {},
): EmbeddedRunAttemptResult {
return {
aborted: false,
timedOut: false,
promptError: null,
sessionIdUsed: "test-session",
assistantTexts: ["Hello!"],
toolMetas: [],
lastAssistant: undefined,
messagesSnapshot: [],
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentTargets: [],
cloudCodeAssistFormatError: false,
...overrides,
};
}
const baseParams = {
sessionId: "test-session",
sessionKey: "test-key",
sessionFile: "/tmp/session.json",
workspaceDir: "/tmp/workspace",
prompt: "hello",
timeoutMs: 30000,
runId: "run-1",
};
describe("overflow compaction in run loop", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retries after successful compaction on context overflow promptError", async () => {
const overflowError = new Error("request_too_large: Request size exceeds model context window");
mockedRunEmbeddedAttempt
.mockResolvedValueOnce(makeAttemptResult({ promptError: overflowError }))
.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
mockedCompactDirect.mockResolvedValueOnce({
ok: true,
compacted: true,
result: {
summary: "Compacted session",
firstKeptEntryId: "entry-5",
tokensBefore: 150000,
},
});
const result = await runEmbeddedPiAgent(baseParams);
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedCompactDirect).toHaveBeenCalledWith(
expect.objectContaining({ authProfileId: "test-profile" }),
);
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
expect(log.warn).toHaveBeenCalledWith(
expect.stringContaining("context overflow detected; attempting auto-compaction"),
);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining("auto-compaction succeeded"));
// Should not be an error result
expect(result.meta.error).toBeUndefined();
});
it("returns error if compaction fails", async () => {
const overflowError = new Error("request_too_large: Request size exceeds model context window");
mockedRunEmbeddedAttempt.mockResolvedValue(makeAttemptResult({ promptError: overflowError }));
mockedCompactDirect.mockResolvedValueOnce({
ok: false,
compacted: false,
reason: "nothing to compact",
});
const result = await runEmbeddedPiAgent(baseParams);
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
expect(result.meta.error?.kind).toBe("context_overflow");
expect(result.payloads?.[0]?.isError).toBe(true);
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("auto-compaction failed"));
});
it("returns error if overflow happens again after compaction", async () => {
const overflowError = new Error("request_too_large: Request size exceeds model context window");
mockedRunEmbeddedAttempt
.mockResolvedValueOnce(makeAttemptResult({ promptError: overflowError }))
.mockResolvedValueOnce(makeAttemptResult({ promptError: overflowError }));
mockedCompactDirect.mockResolvedValueOnce({
ok: true,
compacted: true,
result: {
summary: "Compacted",
firstKeptEntryId: "entry-3",
tokensBefore: 180000,
},
});
const result = await runEmbeddedPiAgent(baseParams);
// Compaction attempted only once
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
// Two attempts: first overflow -> compact -> retry -> second overflow -> return error
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
expect(result.meta.error?.kind).toBe("context_overflow");
expect(result.payloads?.[0]?.isError).toBe(true);
});
it("does not attempt compaction for compaction_failure errors", async () => {
const compactionFailureError = new Error(
"request_too_large: summarization failed - Request size exceeds model context window",
);
mockedRunEmbeddedAttempt.mockResolvedValue(
makeAttemptResult({ promptError: compactionFailureError }),
);
const result = await runEmbeddedPiAgent(baseParams);
expect(mockedCompactDirect).not.toHaveBeenCalled();
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
expect(result.meta.error?.kind).toBe("compaction_failure");
});
});
+3 -38
View File
@@ -42,7 +42,6 @@ import {
} from "../pi-embedded-helpers.js";
import { normalizeUsage, type UsageLike } from "../usage.js";
import { compactEmbeddedPiSessionDirect } from "./compact.js";
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
import { log } from "./logger.js";
import { resolveModel } from "./model.js";
@@ -291,7 +290,6 @@ export async function runEmbeddedPiAgent(
}
}
let overflowCompactionAttempted = false;
try {
while (true) {
attemptedThinking.add(thinkLevel);
@@ -360,42 +358,9 @@ export async function runEmbeddedPiAgent(
if (promptError && !aborted) {
const errorText = describeUnknownError(promptError);
if (isContextOverflowError(errorText)) {
const isCompactionFailure = isCompactionFailureError(errorText);
// Attempt auto-compaction on context overflow (not compaction_failure)
if (!isCompactionFailure && !overflowCompactionAttempted) {
log.warn(
`context overflow detected; attempting auto-compaction for ${provider}/${modelId}`,
);
overflowCompactionAttempted = true;
const compactResult = await compactEmbeddedPiSessionDirect({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
agentAccountId: params.agentAccountId,
authProfileId: lastProfileId,
sessionFile: params.sessionFile,
workspaceDir: params.workspaceDir,
agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
provider,
model: modelId,
thinkLevel,
reasoningLevel: params.reasoningLevel,
bashElevated: params.bashElevated,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
});
if (compactResult.compacted) {
log.info(`auto-compaction succeeded for ${provider}/${modelId}; retrying prompt`);
continue;
}
log.warn(
`auto-compaction failed for ${provider}/${modelId}: ${compactResult.reason ?? "nothing to compact"}`,
);
}
const kind = isCompactionFailure ? "compaction_failure" : "context_overflow";
const kind = isCompactionFailureError(errorText)
? "compaction_failure"
: "context_overflow";
return {
payloads: [
{
@@ -177,11 +177,13 @@ describe("createTelegramBot", () => {
expect(throttlerSpy).toHaveBeenCalledTimes(1);
expect(useSpy).toHaveBeenCalledWith("throttler");
});
it("uses wrapped fetch when global fetch is available", () => {
it("forces native fetch only under Bun", () => {
const originalFetch = globalThis.fetch;
const originalBun = (globalThis as { Bun?: unknown }).Bun;
const fetchSpy = vi.fn() as unknown as typeof fetch;
globalThis.fetch = fetchSpy;
try {
(globalThis as { Bun?: unknown }).Bun = {};
createTelegramBot({ token: "tok" });
const fetchImpl = resolveTelegramFetch();
expect(fetchImpl).toBeTypeOf("function");
@@ -192,6 +194,33 @@ describe("createTelegramBot", () => {
expect(clientFetch).not.toBe(fetchSpy);
} finally {
globalThis.fetch = originalFetch;
if (originalBun === undefined) {
delete (globalThis as { Bun?: unknown }).Bun;
} else {
(globalThis as { Bun?: unknown }).Bun = originalBun;
}
}
});
it("does not force native fetch on Node", () => {
const originalFetch = globalThis.fetch;
const originalBun = (globalThis as { Bun?: unknown }).Bun;
const fetchSpy = vi.fn() as unknown as typeof fetch;
globalThis.fetch = fetchSpy;
try {
if (originalBun !== undefined) {
delete (globalThis as { Bun?: unknown }).Bun;
}
createTelegramBot({ token: "tok" });
const fetchImpl = resolveTelegramFetch();
expect(fetchImpl).toBeUndefined();
expect(botCtorSpy).toHaveBeenCalledWith("tok", undefined);
} finally {
globalThis.fetch = originalFetch;
if (originalBun === undefined) {
delete (globalThis as { Bun?: unknown }).Bun;
} else {
(globalThis as { Bun?: unknown }).Bun = originalBun;
}
}
});
it("passes timeoutSeconds even without a custom fetch", () => {
+31 -1
View File
@@ -309,11 +309,13 @@ describe("createTelegramBot", () => {
expect(registered.some((command) => reserved.includes(command.command))).toBe(false);
});
it("uses wrapped fetch when global fetch is available", () => {
it("forces native fetch only under Bun", () => {
const originalFetch = globalThis.fetch;
const originalBun = (globalThis as { Bun?: unknown }).Bun;
const fetchSpy = vi.fn() as unknown as typeof fetch;
globalThis.fetch = fetchSpy;
try {
(globalThis as { Bun?: unknown }).Bun = {};
createTelegramBot({ token: "tok" });
const fetchImpl = resolveTelegramFetch();
expect(fetchImpl).toBeTypeOf("function");
@@ -324,6 +326,34 @@ describe("createTelegramBot", () => {
expect(clientFetch).not.toBe(fetchSpy);
} finally {
globalThis.fetch = originalFetch;
if (originalBun === undefined) {
delete (globalThis as { Bun?: unknown }).Bun;
} else {
(globalThis as { Bun?: unknown }).Bun = originalBun;
}
}
});
it("does not force native fetch on Node", () => {
const originalFetch = globalThis.fetch;
const originalBun = (globalThis as { Bun?: unknown }).Bun;
const fetchSpy = vi.fn() as unknown as typeof fetch;
globalThis.fetch = fetchSpy;
try {
if (originalBun !== undefined) {
delete (globalThis as { Bun?: unknown }).Bun;
}
createTelegramBot({ token: "tok" });
const fetchImpl = resolveTelegramFetch();
expect(fetchImpl).toBeUndefined();
expect(botCtorSpy).toHaveBeenCalledWith("tok", undefined);
} finally {
globalThis.fetch = originalFetch;
if (originalBun === undefined) {
delete (globalThis as { Bun?: unknown }).Bun;
} else {
(globalThis as { Bun?: unknown }).Bun = originalBun;
}
}
});
+2 -1
View File
@@ -117,7 +117,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const telegramCfg = account.config;
const fetchImpl = resolveTelegramFetch(opts.proxyFetch);
const shouldProvideFetch = Boolean(fetchImpl);
const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun);
const shouldProvideFetch = Boolean(opts.proxyFetch) || isBun;
const timeoutSeconds =
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
? Math.max(1, Math.floor(telegramCfg.timeoutSeconds))
+28 -19
View File
@@ -1,28 +1,37 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { resolveTelegramFetch } from "./fetch.js";
describe("resolveTelegramFetch", () => {
const originalFetch = globalThis.fetch;
it("wraps proxy fetch to normalize foreign abort signals", async () => {
let seenSignal: AbortSignal | undefined;
const proxyFetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
seenSignal = init?.signal as AbortSignal | undefined;
return {} as Response;
});
afterEach(() => {
if (originalFetch) {
globalThis.fetch = originalFetch;
} else {
delete (globalThis as { fetch?: typeof fetch }).fetch;
}
});
const fetcher = resolveTelegramFetch(proxyFetch);
expect(fetcher).toBeTypeOf("function");
it("returns wrapped global fetch when available", () => {
const fetchMock = vi.fn(async () => ({}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const resolved = resolveTelegramFetch();
expect(resolved).toBeTypeOf("function");
});
let abortHandler: (() => void) | null = null;
const fakeSignal = {
aborted: false,
addEventListener: (event: string, handler: () => void) => {
if (event === "abort") abortHandler = handler;
},
removeEventListener: (event: string, handler: () => void) => {
if (event === "abort" && abortHandler === handler) abortHandler = null;
},
} as AbortSignal;
it("prefers proxy fetch when provided", () => {
const fetchMock = vi.fn(async () => ({}));
const resolved = resolveTelegramFetch(fetchMock as unknown as typeof fetch);
expect(resolved).toBeTypeOf("function");
const promise = fetcher!("https://example.com", { signal: fakeSignal });
expect(proxyFetch).toHaveBeenCalledOnce();
expect(seenSignal).toBeInstanceOf(AbortSignal);
expect(seenSignal).not.toBe(fakeSignal);
abortHandler?.();
expect(seenSignal?.aborted).toBe(true);
await promise;
});
});
+3 -1
View File
@@ -1,8 +1,10 @@
import { resolveFetch } from "../infra/fetch.js";
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
// Bun-only: force native fetch to avoid grammY's Node shim under Bun.
export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined {
if (proxyFetch) return resolveFetch(proxyFetch);
const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun);
if (!isBun) return undefined;
const fetchImpl = resolveFetch();
if (!fetchImpl) {
throw new Error("fetch is not available; set channels.telegram.proxy in config");
-3
View File
@@ -1,8 +1,5 @@
import { afterAll, afterEach, beforeEach, vi } from "vitest";
// Ensure Vitest environment is properly set
process.env.VITEST = "true";
import type {
ChannelId,
ChannelOutboundAdapter,