diff --git a/README.md b/README.md index d9654ff..d54f0f9 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ See `config/agents.example.json` for the full schema. Key fields: "model": "claude-sonnet-4-6", "toolPolicy": "readonly", "replyMode": "direct", // reply in-place + "maxBudgetUsd": null, // disable SDK budget limit; use a number to cap cost "persona": "./personas/assistant.md", "knowledge": ["team.md"] }, @@ -156,6 +157,7 @@ All options are documented in `.env.example`. Key ones: | `ANTHROPIC_API_KEY` | Yes | Anthropic API key | | `FEISHU_EVENT_MODE` | No | `websocket` (default) or `webhook` | | `DEFAULT_WORK_DIR` | No | Root directory for projects (default: parent of cwd) | +| `CLAUDE_MAX_BUDGET_USD` | No | Per-query SDK budget in USD; set `off`, `false`, `none`, `disabled`, or `0` to omit it | | `ALLOWED_USER_IDS` | No | Comma-separated Feishu open_ids (empty = allow all) | | `OWNER_USER_ID` | No | Admin user with full permissions | | `MEMORY_ENABLED` | No | Enable long-term memory system | diff --git a/src/__tests__/config-loader.test.ts b/src/__tests__/config-loader.test.ts index 7951997..93bc6c7 100644 --- a/src/__tests__/config-loader.test.ts +++ b/src/__tests__/config-loader.test.ts @@ -71,6 +71,13 @@ describe('AgentConfigFileSchema', () => { expect(result.success).toBe(false); }); + it('accepts null maxBudgetUsd to disable budget', () => { + const result = AgentConfigFileSchema.safeParse({ + defaults: { maxBudgetUsd: null }, + agents: [{ id: 'test', maxBudgetUsd: null }], + }); + expect(result.success).toBe(true); + }); it('rejects zero maxTurns', () => { const result = AgentConfigFileSchema.safeParse({ agents: [{ id: 'test', maxTurns: 0 }], diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index fb3696a..b0b880b 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -49,6 +49,24 @@ describe('config', () => { const { config } = await loadConfig(); expect(config.claude.toolTimeoutSeconds).toBe(1200); }); + + it('should parse CLAUDE_MAX_BUDGET_USD as a number', async () => { + vi.stubEnv('CLAUDE_MAX_BUDGET_USD', '12.5'); + const { config } = await loadConfig(); + expect(config.claude.maxBudgetUsd).toBe(12.5); + }); + + it('should disable CLAUDE_MAX_BUDGET_USD when set to off', async () => { + vi.stubEnv('CLAUDE_MAX_BUDGET_USD', 'off'); + const { config } = await loadConfig(); + expect(config.claude.maxBudgetUsd).toBeUndefined(); + }); + + it('should disable CLAUDE_MAX_BUDGET_USD when set to 0', async () => { + vi.stubEnv('CLAUDE_MAX_BUDGET_USD', '0'); + const { config } = await loadConfig(); + expect(config.claude.maxBudgetUsd).toBeUndefined(); + }); }); describe('defaults', () => { diff --git a/src/agent/__tests__/config-loader.test.ts b/src/agent/__tests__/config-loader.test.ts index cf8dec9..74902c9 100644 --- a/src/agent/__tests__/config-loader.test.ts +++ b/src/agent/__tests__/config-loader.test.ts @@ -410,4 +410,20 @@ describe('maxBudgetUsd / maxTurns fallback', () => { const { agentRegistry } = await import('../registry.js'); expect(agentRegistry.get('pm')!.maxBudgetUsd).toBe(100); }); + it('should let file-level null maxBudgetUsd disable SDK budget', async () => { + await setupWithClaudeConfig( + { defaults: { maxBudgetUsd: null }, agents: [{ id: 'pm' }] }, + { maxBudgetUsd: 50 }, + ); + const { agentRegistry } = await import('../registry.js'); + expect(agentRegistry.get('pm')!.maxBudgetUsd).toBeUndefined(); + }); + it('should let agent-level null maxBudgetUsd override defaults and config', async () => { + await setupWithClaudeConfig( + { defaults: { maxBudgetUsd: 20 }, agents: [{ id: 'pm', maxBudgetUsd: null }] }, + { maxBudgetUsd: 50 }, + ); + const { agentRegistry } = await import('../registry.js'); + expect(agentRegistry.get('pm')!.maxBudgetUsd).toBeUndefined(); + }); }); diff --git a/src/agent/config-loader.ts b/src/agent/config-loader.ts index 7ab06a9..7bc9d28 100644 --- a/src/agent/config-loader.ts +++ b/src/agent/config-loader.ts @@ -75,6 +75,16 @@ function resolveToolPolicy(policy: ToolPolicyValue | undefined): { } // ─── Merge 逻辑 ──────────────────────────────────────────── +function resolveMaxBudgetUsd(input: AgentConfigInput, defaults: AgentDefaults): number | undefined { + if (input.maxBudgetUsd !== undefined) { + return input.maxBudgetUsd === null ? undefined : input.maxBudgetUsd; + } + if (defaults.maxBudgetUsd !== undefined) { + return defaults.maxBudgetUsd === null ? undefined : defaults.maxBudgetUsd; + } + return config.claude.maxBudgetUsd; +} + /** * 合并单个 agent 配置:input → defaults → 内置默认值 @@ -92,7 +102,7 @@ function mergeAgentConfig(input: AgentConfigInput, defaults: AgentDefaults): Age toolPolicy, readOnly, settingSources: (input.settingSources ?? defaults.settingSources ?? BUILTIN_DEFAULTS.settingSources!) as ('user' | 'project')[], - maxBudgetUsd: input.maxBudgetUsd ?? defaults.maxBudgetUsd ?? config.claude.maxBudgetUsd, + maxBudgetUsd: resolveMaxBudgetUsd(input, defaults), maxTurns: input.maxTurns ?? defaults.maxTurns ?? config.claude.maxTurns, requiresApproval: input.requiresApproval ?? defaults.requiresApproval ?? BUILTIN_DEFAULTS.requiresApproval!, replyMode: input.replyMode ?? defaults.replyMode ?? BUILTIN_DEFAULTS.replyMode! as 'direct' | 'thread', diff --git a/src/agent/config-schema.ts b/src/agent/config-schema.ts index 1713c47..31c04e9 100644 --- a/src/agent/config-schema.ts +++ b/src/agent/config-schema.ts @@ -23,6 +23,9 @@ const ToolPolicyDetailedSchema = z.object({ /** toolPolicy 支持两种格式 */ export const ToolPolicySchema = z.union([ToolPolicySimpleSchema, ToolPolicyDetailedSchema]); +/** 单次 query 最大花费 (USD);null 表示不向 SDK 传 maxBudgetUsd */ +const MaxBudgetUsdSchema = z.number().positive().max(1000).nullable(); + // ─── 飞书账号 ──────────────────────────────────────────────── @@ -66,7 +69,7 @@ export const AgentConfigInputSchema = z.object({ /** Settings 源 */ settingSources: z.array(z.enum(['user', 'project'])).optional(), /** 单次 query 最大花费 (USD),上限 1000 */ - maxBudgetUsd: z.number().positive().max(1000).optional(), + maxBudgetUsd: MaxBudgetUsdSchema.optional(), /** 单次 query 最大轮次,上限 10000 */ maxTurns: z.number().int().positive().max(10000).optional(), /** 是否需要写权限审批 */ @@ -91,7 +94,7 @@ export const AgentDefaultsSchema = z.object({ model: z.string().optional(), toolPolicy: ToolPolicySchema.optional(), settingSources: z.array(z.enum(['user', 'project'])).optional(), - maxBudgetUsd: z.number().positive().max(1000).optional(), + maxBudgetUsd: MaxBudgetUsdSchema.optional(), maxTurns: z.number().int().positive().max(10000).optional(), requiresApproval: z.boolean().optional(), replyMode: z.enum(['direct', 'thread']).optional(), diff --git a/src/agent/types.ts b/src/agent/types.ts index 84749b9..cf19595 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -47,8 +47,8 @@ export interface AgentConfig { readOnly: boolean; /** 加载哪些 settings 源 */ settingSources: ('user' | 'project')[]; - /** 单次 query 最大花费 (USD) */ - maxBudgetUsd: number; + /** 单次 query 最大花费 (USD)。省略表示不向 SDK 传 maxBudgetUsd */ + maxBudgetUsd?: number; /** 单次 query 最大轮次 */ maxTurns: number; /** 是否需要写权限审批(non-owner 场景) */ diff --git a/src/claude/__tests__/executor.test.ts b/src/claude/__tests__/executor.test.ts index 789d0e3..b5de384 100644 --- a/src/claude/__tests__/executor.test.ts +++ b/src/claude/__tests__/executor.test.ts @@ -226,6 +226,25 @@ describe('ClaudeExecutor', () => { expect(opts.maxTurns).toBe(5); expect(opts.maxBudgetUsd).toBe(0.5); }); + + it('should omit maxBudgetUsd when config disables it', async () => { + const { config } = await import('../../config.js'); + const prevBudget = config.claude.maxBudgetUsd; + config.claude.maxBudgetUsd = undefined; + try { + await executor.execute(makeInput()); + const opts = mockQuery.mock.calls[0][0].options; + expect(opts).not.toHaveProperty('maxBudgetUsd'); + } finally { + config.claude.maxBudgetUsd = prevBudget; + } + }); + + it('should omit maxBudgetUsd when caller explicitly disables it', async () => { + await executor.execute(makeInput({ maxBudgetUsd: undefined })); + const opts = mockQuery.mock.calls[0][0].options; + expect(opts).not.toHaveProperty('maxBudgetUsd'); + }); }); describe('killSessionsForChat', () => { diff --git a/src/claude/executor.ts b/src/claude/executor.ts index 00c502e..1b6db72 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -648,6 +648,9 @@ export class ClaudeExecutor { readOnly, images, documents, } = input; + const hasMaxBudgetUsdOverride = Object.prototype.hasOwnProperty.call(input, 'maxBudgetUsd'); + const effectiveMaxBudgetUsd = hasMaxBudgetUsdOverride ? maxBudgetUsd : config.claude.maxBudgetUsd; + const startTime = Date.now(); const abortController = new AbortController(); const idleTimeoutMs = (input.timeoutSeconds ?? config.claude.timeoutSeconds) * 1000; @@ -1041,7 +1044,7 @@ export class ClaudeExecutor { // 预算和限制 — 默认值从 config 读取,调用方可覆盖 maxTurns: maxTurns ?? config.claude.maxTurns, - maxBudgetUsd: maxBudgetUsd ?? config.claude.maxBudgetUsd, + ...(effectiveMaxBudgetUsd !== undefined ? { maxBudgetUsd: effectiveMaxBudgetUsd } : {}), // 会话续接 ...(effectiveResumeId ? { resume: effectiveResumeId } : {}), diff --git a/src/claude/types.ts b/src/claude/types.ts index 7539df2..dceff94 100644 --- a/src/claude/types.ts +++ b/src/claude/types.ts @@ -91,7 +91,7 @@ export interface CompactResult { export interface ExecuteOptions { /** 覆盖默认 maxTurns (默认 50) */ maxTurns?: number; - /** 覆盖默认 maxBudgetUsd (默认 5) */ + /** 覆盖默认 maxBudgetUsd;显式传 undefined 表示不向 SDK 传预算限制 */ maxBudgetUsd?: number; /** 不注入 setup_workspace MCP tool (restart 时使用) */ disableWorkspaceTool?: boolean; diff --git a/src/config.ts b/src/config.ts index efbb938..cb422f9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,26 @@ export function parsePositiveInt(raw: string | undefined, fallback: number): num return Number.isFinite(n) && n > 0 ? n : fallback; } +/** + * CLAUDE_MAX_BUDGET_USD 解析: + * - 未设置/空字符串:使用 fallback + * - off/false/none/disabled/0:关闭 SDK maxBudgetUsd,不向 SDK 传该字段 + * - 正数:启用预算熔断 + */ +export function parseOptionalMaxBudgetUsd(raw: string | undefined, fallback: number): number | undefined { + const value = raw?.trim(); + if (!value) return fallback; + + const normalized = value.toLowerCase(); + if (['off', 'false', 'none', 'disabled', 'disable'].includes(normalized)) { + return undefined; + } + + const n = Number(value); + if (n === 0) return undefined; + return Number.isFinite(n) && n > 0 ? n : fallback; +} + function parseGroupConfigs(raw?: string): Record { if (!raw?.trim()) return {}; try { @@ -91,8 +111,8 @@ export const config = { effort: (process.env.CLAUDE_EFFORT || 'xhigh') as 'low' | 'medium' | 'high' | 'xhigh' | 'max', /** 单次 query 最大轮次 (Agent ↔ Tool 来回次数),兜底防死循环 */ maxTurns: parseInt(process.env.CLAUDE_MAX_TURNS || '500', 10), - /** 单次 query 最大花费 (美元),真正的费用熔断 */ - maxBudgetUsd: parseFloat(process.env.CLAUDE_MAX_BUDGET_USD || '50'), + /** 单次 query 最大花费 (美元)。设置 CLAUDE_MAX_BUDGET_USD=off/false/none/disabled/0 可关闭 */ + maxBudgetUsd: parseOptionalMaxBudgetUsd(process.env.CLAUDE_MAX_BUDGET_USD, 50), }, // 工作区配置 diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index f8dcce5..5fe2c02 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -1444,7 +1444,7 @@ async function handleSlashCommand( `**工具策略**: ${agentCfg?.toolPolicy ?? '(默认)'}`, `**回复模式**: ${agentCfg?.replyMode ?? '(默认)'}`, `**需要审批**: ${agentCfg?.requiresApproval ? '是' : '否'}`, - `**预算**: $${agentCfg?.maxBudgetUsd ?? '?'} / ${agentCfg?.maxTurns ?? '?'} turns`, + `**预算**: ${agentCfg ? (agentCfg.maxBudgetUsd === undefined ? '关闭' : '$' + agentCfg.maxBudgetUsd) : '?'} / ${agentCfg?.maxTurns ?? '?'} turns`, ]; if (agentCfg?.editablePathPatterns?.length) { @@ -2799,7 +2799,7 @@ export async function executeClaudeTask( readOnly, model: agentCfg?.model, maxTurns: agentCfg?.maxTurns, - maxBudgetUsd: agentCfg?.maxBudgetUsd, + ...(agentCfg ? { maxBudgetUsd: agentCfg.maxBudgetUsd } : {}), settingSources: agentCfg?.settingSources, toolAllow: agentCfg?.toolAllow, toolDeny: agentCfg?.toolDeny, @@ -2950,7 +2950,7 @@ export async function executeClaudeTask( readOnly, model: agentCfg?.model, maxTurns: agentCfg?.maxTurns, - maxBudgetUsd: agentCfg?.maxBudgetUsd, + ...(agentCfg ? { maxBudgetUsd: agentCfg.maxBudgetUsd } : {}), settingSources: agentCfg?.settingSources, toolAllow: agentCfg?.toolAllow, toolDeny: agentCfg?.toolDeny,