feat(coding-agent): added Ollama context discovery and attribution control
- Added automatic Ollama model context window discovery from metadata for accurate token limits. - Added `attribution` option to `PromptOptions` for explicit billing and initiator attribution control. - Added automatic clearing of completed and abandoned todo tasks after ~1 minute. - Changed session directory migration to use `-tmp-` prefix instead of double-dash format. - Updated Ollama model registration to use discovered context window instead of hardcoded 128000 token default. Fixes #440
This commit is contained in:
@@ -1,13 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Automatic discovery of Ollama model context window from model metadata, enabling accurate token limit configuration
|
||||
- Added `attribution` option to `PromptOptions` to explicitly control billing/initiator attribution for prompts
|
||||
- Added automatic clearing of completed and abandoned todo tasks after ~1 minute
|
||||
|
||||
### Changed
|
||||
|
||||
- Ollama model registration now uses discovered context window instead of hardcoded 128000 token default
|
||||
- Ollama model maxTokens now respects discovered context window constraints
|
||||
- Improved session directory migration to handle legacy absolute paths with double-dash format, automatically relocating them to new canonical locations
|
||||
- Enhanced session directory encoding to use `-tmp-` prefix for temporary directories instead of legacy double-dash format for better clarity
|
||||
- Updated `SessionManager.create()` to require both `cwd` and `sessionDir` parameters for explicit session directory control
|
||||
|
||||
@@ -161,11 +161,17 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
|
||||
let retryCount = 0;
|
||||
const needsChangelog = input.requireChangelog && input.changelogTargets.length > 0;
|
||||
|
||||
await session.prompt(prompt, { expandPromptTemplates: false });
|
||||
await session.prompt(prompt, {
|
||||
attribution: "agent",
|
||||
expandPromptTemplates: false,
|
||||
});
|
||||
while (retryCount < MAX_RETRIES && !isProposalComplete(state, needsChangelog)) {
|
||||
retryCount += 1;
|
||||
const reminder = buildReminderMessage(state, needsChangelog, retryCount, MAX_RETRIES);
|
||||
await session.prompt(reminder, { expandPromptTemplates: false });
|
||||
await session.prompt(reminder, {
|
||||
attribution: "agent",
|
||||
expandPromptTemplates: false,
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
|
||||
@@ -366,6 +366,12 @@ interface CustomModelsResult {
|
||||
found: boolean;
|
||||
}
|
||||
|
||||
type OllamaDiscoveredModelMetadata = {
|
||||
reasoning: boolean;
|
||||
input: ("text" | "image")[];
|
||||
contextWindow?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve an API key config value to an actual key.
|
||||
* Checks environment variable first, then treats as literal.
|
||||
@@ -376,6 +382,40 @@ function resolveApiKeyConfig(keyConfig: string): string | undefined {
|
||||
return keyConfig;
|
||||
}
|
||||
|
||||
function toPositiveNumberOrUndefined(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractOllamaContextWindow(payload: Record<string, unknown>): number | undefined {
|
||||
const modelInfo = payload.model_info;
|
||||
if (isRecord(modelInfo)) {
|
||||
for (const [key, value] of Object.entries(modelInfo)) {
|
||||
if (key === "context_length" || key.endsWith(".context_length")) {
|
||||
const contextWindow = toPositiveNumberOrUndefined(value);
|
||||
if (contextWindow !== undefined) {
|
||||
return contextWindow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parameters = payload.parameters;
|
||||
if (typeof parameters !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m);
|
||||
return match ? toPositiveNumberOrUndefined(match[1]) : undefined;
|
||||
}
|
||||
|
||||
function extractGoogleOAuthToken(value: string | undefined): string | undefined {
|
||||
if (!isAuthenticated(value)) return undefined;
|
||||
try {
|
||||
@@ -1096,7 +1136,7 @@ export class ModelRegistry {
|
||||
endpoint: string,
|
||||
modelId: string,
|
||||
headers: Record<string, string> | undefined,
|
||||
): Promise<{ reasoning: boolean; input: ("text" | "image")[] } | null> {
|
||||
): Promise<OllamaDiscoveredModelMetadata | null> {
|
||||
const showUrl = `${endpoint}/api/show`;
|
||||
try {
|
||||
const response = await fetch(showUrl, {
|
||||
@@ -1112,6 +1152,7 @@ export class ModelRegistry {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
const contextWindow = extractOllamaContextWindow(payload);
|
||||
const capabilities = payload.capabilities;
|
||||
if (Array.isArray(capabilities)) {
|
||||
const normalized = new Set(
|
||||
@@ -1121,15 +1162,21 @@ export class ModelRegistry {
|
||||
return {
|
||||
reasoning: normalized.has("thinking"),
|
||||
input: supportsVision ? ["text", "image"] : ["text"],
|
||||
contextWindow,
|
||||
};
|
||||
}
|
||||
if (!isRecord(capabilities)) {
|
||||
return null;
|
||||
return {
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow,
|
||||
};
|
||||
}
|
||||
const supportsVision = capabilities.vision === true || capabilities.image === true;
|
||||
return {
|
||||
reasoning: capabilities.thinking === true,
|
||||
input: supportsVision ? ["text", "image"] : ["text"],
|
||||
contextWindow,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -1170,8 +1217,8 @@ export class ModelRegistry {
|
||||
reasoning: metadata?.reasoning ?? false,
|
||||
input: metadata?.input ?? ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
contextWindow: metadata?.contextWindow ?? 128000,
|
||||
maxTokens: Math.min(metadata?.contextWindow ?? Number.POSITIVE_INFINITY, 8192),
|
||||
headers: providerConfig.headers,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-ai";
|
||||
import { runCommitAgentSession } from "../src/commit/agentic/agent";
|
||||
import * as toolsModule from "../src/commit/agentic/tools";
|
||||
import { Settings } from "../src/config/settings";
|
||||
import * as sdkModule from "../src/sdk";
|
||||
import type { PromptOptions } from "../src/session/agent-session";
|
||||
|
||||
vi.mock("../src/sdk", () => ({
|
||||
createAgentSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../src/commit/agentic/tools", () => ({
|
||||
createCommitTools: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
describe("commit agent prompt attribution", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("marks generated commit prompts and reminders as agent-attributed", async () => {
|
||||
const prompts: Array<{ text: string; options?: PromptOptions }> = [];
|
||||
const session = {
|
||||
prompt: async (text: string, options?: PromptOptions) => {
|
||||
prompts.push({ text, options });
|
||||
},
|
||||
subscribe: () => () => {},
|
||||
dispose: async () => {},
|
||||
};
|
||||
|
||||
(sdkModule.createAgentSession as unknown as { mockResolvedValue: (value: unknown) => void }).mockResolvedValue({
|
||||
session,
|
||||
});
|
||||
(toolsModule.createCommitTools as unknown as { mockReturnValue: (value: unknown) => void }).mockReturnValue([]);
|
||||
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
|
||||
if (!model) {
|
||||
throw new Error("Expected claude-sonnet-4-5 model to exist");
|
||||
}
|
||||
|
||||
await runCommitAgentSession({
|
||||
cwd: "/tmp",
|
||||
git: {} as never,
|
||||
model,
|
||||
settings: Settings.isolated(),
|
||||
modelRegistry: {} as never,
|
||||
authStorage: {} as never,
|
||||
changelogTargets: [],
|
||||
requireChangelog: false,
|
||||
});
|
||||
|
||||
expect(prompts).toHaveLength(4);
|
||||
for (const prompt of prompts) {
|
||||
expect(prompt.options?.attribution).toBe("agent");
|
||||
expect(prompt.options?.expandPromptTemplates).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -909,6 +909,44 @@ describe("ModelRegistry", () => {
|
||||
expect(llama?.reasoning).toBe(false);
|
||||
});
|
||||
|
||||
test("discovers ollama context window from show model_info", async () => {
|
||||
using _hook = hookFetch((input, init) => {
|
||||
const url = String(input);
|
||||
if (url === "http://127.0.0.1:11434/api/tags") {
|
||||
return new Response(JSON.stringify({ models: [{ name: "gemma3:4b" }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "http://127.0.0.1:11434/api/show") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { model?: string };
|
||||
if (body.model === "gemma3:4b") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
model_info: {
|
||||
"gemma3.context_length": 131072,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
await registry.refresh();
|
||||
|
||||
const gemma = registry.find("ollama", "gemma3:4b");
|
||||
expect(gemma?.contextWindow).toBe(131072);
|
||||
expect(gemma?.maxTokens).toBe(8192);
|
||||
expect(gemma?.input).toEqual(["text"]);
|
||||
expect(gemma?.reasoning).toBe(false);
|
||||
});
|
||||
|
||||
test("discovery failure does not fail model registry refresh", async () => {
|
||||
writeRawModelsJson({
|
||||
ollama: {
|
||||
|
||||
Reference in New Issue
Block a user