feat: per-model max output tokens and a conversation-time thinking level backed by agent settings (#28)

Co-authored-by: Alice <alice@prismshadow.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-07-22 22:22:41 +08:00
1 parent 99f391f379
commit cabbab1c16
36 files changed
+797 -42

No files matched your search

+97 -1
View File
@@ -22,7 +22,7 @@ import {
installSkill,
setVaultEntry,
} from "../src/index.js";
import { effectiveMaxContextLength } from "../src/agent.js";
import { effectiveMaxContextLength, metaMaxTokens } from "../src/agent.js";
import { stubProviderKeys } from "./provider-keys.js";
let tmpRoot: string;
@@ -53,6 +53,15 @@ describe("effectiveMaxContextLength (compaction threshold clamped to the model w
});
});
describe("metaMaxTokens (meta-request budget tightened by the per-model cap)", () => {
it("keeps the budget unless the per-model cap is smaller; never raises it", () => {
expect(metaMaxTokens(300, undefined)).toBe(300); // no per-model cap: the budget as-is
expect(metaMaxTokens(300, 8000)).toBe(300); // ample cap: the small budget stays
expect(metaMaxTokens(300, 128)).toBe(128); // pinned below the budget: the cap binds
expect(metaMaxTokens(2048, 1024)).toBe(1024); // vision-describer budget, same rule
});
});
describe("Agent.createSession workspace handling", () => {
it("throws a clear error when the given workspace does not exist (no auto-create)", async () => {
const agent = await createAgent();
@@ -192,6 +201,93 @@ describe("Agent.createSession model reference ((provider, model_id) pair)", () =
});
});
describe("Agent.createSession max output tokens (per-model cap wins over the Agent config)", () => {
// Reads a constructed GenerativeModel's request config (private; runtime-accessible for assertion).
const uniConfigOf = (llm: unknown) =>
(llm as { uniConfig?: { max_tokens?: number } }).uniConfig ?? {};
it("uses the entry's max_tokens in llmConfig, and inherits the seeded 32000 when unset", async () => {
// A 32k-context local model: the seeded per-Agent default (32000 output tokens) cannot fit
// into its window together with any prompt — the pinned per-model cap must win.
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
provider: "custom",
model_id: "local-32k",
client_type: "openai",
context_window: 32768,
max_tokens: 8000,
});
const agent = await createAgent();
expect(agent.state.systemConfig.model?.max_tokens).toBe(32000);
const ws = path.join(tmpRoot, "ws-max-tokens");
await fs.mkdir(ws, { recursive: true });
const pinned = await agent.createSession({
workspaceDir: ws,
modelId: "local-32k",
provider: "custom",
});
try {
const llm = (pinned as unknown as { engine: { deps: { llm: unknown } } }).engine.deps.llm;
expect(uniConfigOf(llm).max_tokens).toBe(8000);
} finally {
pinned.dispose();
}
// An unannotated entry (the default model) inherits the Agent value, as before.
const inherited = await agent.createSession({ workspaceDir: ws });
try {
const llm = (inherited as unknown as { engine: { deps: { llm: unknown } } }).engine.deps.llm;
expect(uniConfigOf(llm).max_tokens).toBe(32000);
} finally {
inherited.dispose();
}
});
it("meta requests keep their small budget, tightened when the per-model cap is even smaller", async () => {
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
provider: "custom",
model_id: "local-32k",
client_type: "openai",
max_tokens: 8000,
});
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
provider: "custom",
model_id: "tiny-cap",
client_type: "openai",
max_tokens: 128,
});
const agent = await createAgent();
const ws = path.join(tmpRoot, "ws-meta-cap");
await fs.mkdir(ws, { recursive: true });
// Ample per-model cap: the title one-shot keeps its own 300 budget (never raised to the cap).
const ample = await agent.createSession({
workspaceDir: ws,
modelId: "local-32k",
provider: "custom",
});
try {
const bare = (ample as unknown as { createBareLLM?: () => unknown }).createBareLLM?.();
expect(uniConfigOf(bare).max_tokens).toBe(300);
} finally {
ample.dispose();
}
// Cap pinned below the budget: the meta request must respect it too.
const tiny = await agent.createSession({
workspaceDir: ws,
modelId: "tiny-cap",
provider: "custom",
});
try {
const bare = (tiny as unknown as { createBareLLM?: () => unknown }).createBareLLM?.();
expect(uniConfigOf(bare).max_tokens).toBe(128);
} finally {
tiny.dispose();
}
});
});
describe("Agent.createSession vault injection", () => {
it("injects vault key names (never values) into the assembled system prompt", async () => {
// Write the Agent vault to disk first; createSession reads that Agent's own .vault.toml.
+2 -2
View File
@@ -5,9 +5,7 @@
import { describe, it, expect } from "vitest";
import {
assistantText,
buildTitlePrompt,
emptyTokenCounts,
generateTitleWithLLM,
sanitizeTitle,
Session,
stripConversationMarkers,
@@ -15,6 +13,8 @@ import {
tokenUsage,
userText,
} from "../src/index.js";
// Prompt/request internals are no longer exported via the barrel: imported directly from the internal module.
import { buildTitlePrompt, generateTitleWithLLM } from "../src/internal/session-title.js";
import type {
EnvironmentInterface,
LLMInterface,
+28
View File
@@ -531,6 +531,7 @@ describe("project-config round trip", () => {
provider: "custom",
model_id: "gpt-test",
context_window: 128000,
max_tokens: 8192,
api_key: "sk-abc",
base_url: "https://example.com/v1",
},
@@ -549,6 +550,7 @@ describe("project-config round trip", () => {
provider: "custom",
model_id: "gpt-test",
context_window: 128000,
max_tokens: 8192,
api_key: "sk-abc",
base_url: "https://example.com/v1",
});
@@ -644,6 +646,32 @@ describe("project-config round trip", () => {
expect(m?.vision).toBe(true);
});
it("addModel persists max_tokens and upsert preserves it when not re-specified", async () => {
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
provider: "custom",
model_id: "small-window",
max_tokens: 4096,
});
// Only supplements context_window, without max_tokens: the original annotation is kept.
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
provider: "custom",
model_id: "small-window",
context_window: 32768,
});
const ref = { provider: "custom", model_id: "small-window" };
let m = getModel(await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID), ref);
expect(m?.max_tokens).toBe(4096);
expect(m?.context_window).toBe(32768);
// Explicitly re-pins the cap.
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
provider: "custom",
model_id: "small-window",
max_tokens: 2048,
});
m = getModel(await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID), ref);
expect(m?.max_tokens).toBe(2048);
});
it("setVisionModel persists and validates the target", async () => {
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
provider: "custom",