mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-15 08:03:42 +00:00
415 lines
13 KiB
TypeScript
415 lines
13 KiB
TypeScript
import { join } from "node:path";
|
|
import {
|
|
Agent,
|
|
type AgentMessage,
|
|
type ThinkingLevel,
|
|
} from "@mariozechner/companion-agent-core";
|
|
import type { Message, Model } from "@mariozechner/companion-ai";
|
|
import { getAgentDir, getDocsPath } from "../config.js";
|
|
import { AgentSession } from "./agent-session.js";
|
|
import { AuthStorage } from "./auth-storage.js";
|
|
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
|
|
import type {
|
|
ExtensionRunner,
|
|
LoadExtensionsResult,
|
|
ToolDefinition,
|
|
} from "./extensions/index.js";
|
|
import { convertToLlm } from "./messages.js";
|
|
import { ModelRegistry } from "./model-registry.js";
|
|
import { findInitialModel } from "./model-resolver.js";
|
|
import type { ResourceLoader } from "./resource-loader.js";
|
|
import { DefaultResourceLoader } from "./resource-loader.js";
|
|
import { SessionManager } from "./session-manager.js";
|
|
import { SettingsManager } from "./settings-manager.js";
|
|
import { time } from "./timings.js";
|
|
import {
|
|
allTools,
|
|
bashTool,
|
|
browserTool,
|
|
computerTool,
|
|
codingTools,
|
|
defaultCodingToolNames,
|
|
createBashTool,
|
|
createBrowserTool,
|
|
createComputerTool,
|
|
createCodingTools,
|
|
createEditTool,
|
|
createFindTool,
|
|
createGrepTool,
|
|
createLsTool,
|
|
createReadOnlyTools,
|
|
createReadTool,
|
|
createWriteTool,
|
|
editTool,
|
|
findTool,
|
|
grepTool,
|
|
lsTool,
|
|
readOnlyTools,
|
|
readTool,
|
|
type Tool,
|
|
type ToolName,
|
|
writeTool,
|
|
} from "./tools/index.js";
|
|
|
|
export interface CreateAgentSessionOptions {
|
|
/** Working directory for project-local discovery. Default: process.cwd() */
|
|
cwd?: string;
|
|
/** Global config directory. Default: ~/.companion/agent */
|
|
agentDir?: string;
|
|
|
|
/** Auth storage for credentials. Default: AuthStorage.create(agentDir/auth.json) */
|
|
authStorage?: AuthStorage;
|
|
/** Model registry. Default: new ModelRegistry(authStorage, agentDir/models.json) */
|
|
modelRegistry?: ModelRegistry;
|
|
|
|
/** Model to use. Default: from settings, else first available */
|
|
model?: Model<any>;
|
|
/** Thinking level. Default: from settings, else 'medium' (clamped to model capabilities) */
|
|
thinkingLevel?: ThinkingLevel;
|
|
/** Models available for cycling (Ctrl+P in interactive mode) */
|
|
scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
|
|
|
|
/** Built-in tools to use. Default: codingTools [read, bash, browser, computer, edit, write] */
|
|
tools?: Tool[];
|
|
/** Custom tools to register (in addition to built-in tools). */
|
|
customTools?: ToolDefinition[];
|
|
|
|
/** Resource loader. When omitted, DefaultResourceLoader is used. */
|
|
resourceLoader?: ResourceLoader;
|
|
|
|
/** Session manager. Default: SessionManager.create(cwd) */
|
|
sessionManager?: SessionManager;
|
|
|
|
/** Settings manager. Default: SettingsManager.create(cwd, agentDir) */
|
|
settingsManager?: SettingsManager;
|
|
}
|
|
|
|
/** Result from createAgentSession */
|
|
export interface CreateAgentSessionResult {
|
|
/** The created session */
|
|
session: AgentSession;
|
|
/** Extensions result (for UI context setup in interactive mode) */
|
|
extensionsResult: LoadExtensionsResult;
|
|
/** Warning if session was restored with a different model than saved */
|
|
modelFallbackMessage?: string;
|
|
}
|
|
|
|
// Re-exports
|
|
|
|
export type {
|
|
ExtensionAPI,
|
|
ExtensionCommandContext,
|
|
ExtensionContext,
|
|
ExtensionFactory,
|
|
SlashCommandInfo,
|
|
SlashCommandLocation,
|
|
SlashCommandSource,
|
|
ToolDefinition,
|
|
} from "./extensions/index.js";
|
|
export type { PromptTemplate } from "./prompt-templates.js";
|
|
export type { Skill } from "./skills.js";
|
|
export type { Tool } from "./tools/index.js";
|
|
|
|
export {
|
|
// Pre-built tools (use process.cwd())
|
|
readTool,
|
|
bashTool,
|
|
browserTool,
|
|
computerTool,
|
|
editTool,
|
|
writeTool,
|
|
grepTool,
|
|
findTool,
|
|
lsTool,
|
|
codingTools,
|
|
readOnlyTools,
|
|
allTools as allBuiltInTools,
|
|
// Tool factories (for custom cwd)
|
|
createCodingTools,
|
|
createReadOnlyTools,
|
|
createReadTool,
|
|
createBashTool,
|
|
createBrowserTool,
|
|
createComputerTool,
|
|
createEditTool,
|
|
createWriteTool,
|
|
createGrepTool,
|
|
createFindTool,
|
|
createLsTool,
|
|
};
|
|
|
|
// Helper Functions
|
|
|
|
function getDefaultAgentDir(): string {
|
|
return getAgentDir();
|
|
}
|
|
|
|
/**
|
|
* Create an AgentSession with the specified options.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* // Minimal - uses defaults
|
|
* const { session } = await createAgentSession();
|
|
*
|
|
* // With explicit model
|
|
* import { getModel } from '@mariozechner/companion-ai';
|
|
* const { session } = await createAgentSession({
|
|
* model: getModel('anthropic', 'claude-opus-4-6'),
|
|
* thinkingLevel: 'high',
|
|
* });
|
|
*
|
|
* // Continue previous session
|
|
* const { session, modelFallbackMessage } = await createAgentSession({
|
|
* continueSession: true,
|
|
* });
|
|
*
|
|
* // Full control
|
|
* const loader = new DefaultResourceLoader({
|
|
* cwd: process.cwd(),
|
|
* agentDir: getAgentDir(),
|
|
* settingsManager: SettingsManager.create(),
|
|
* });
|
|
* await loader.reload();
|
|
* const { session } = await createAgentSession({
|
|
* model: myModel,
|
|
* tools: [readTool, bashTool],
|
|
* resourceLoader: loader,
|
|
* sessionManager: SessionManager.inMemory(),
|
|
* });
|
|
* ```
|
|
*/
|
|
export async function createAgentSession(
|
|
options: CreateAgentSessionOptions = {},
|
|
): Promise<CreateAgentSessionResult> {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
const agentDir = options.agentDir ?? getDefaultAgentDir();
|
|
let resourceLoader = options.resourceLoader;
|
|
|
|
// Use provided or create AuthStorage and ModelRegistry
|
|
const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined;
|
|
const modelsPath = options.agentDir
|
|
? join(agentDir, "models.json")
|
|
: undefined;
|
|
const authStorage = options.authStorage ?? AuthStorage.create(authPath);
|
|
const modelRegistry =
|
|
options.modelRegistry ?? new ModelRegistry(authStorage, modelsPath);
|
|
|
|
const settingsManager =
|
|
options.settingsManager ?? SettingsManager.create(cwd, agentDir);
|
|
const sessionManager = options.sessionManager ?? SessionManager.create(cwd);
|
|
|
|
if (!resourceLoader) {
|
|
resourceLoader = new DefaultResourceLoader({
|
|
cwd,
|
|
agentDir,
|
|
settingsManager,
|
|
});
|
|
await resourceLoader.reload();
|
|
time("resourceLoader.reload");
|
|
}
|
|
|
|
// Check if session has existing data to restore
|
|
const existingSession = sessionManager.buildSessionContext();
|
|
const hasExistingSession = existingSession.messages.length > 0;
|
|
const hasThinkingEntry = sessionManager
|
|
.getBranch()
|
|
.some((entry) => entry.type === "thinking_level_change");
|
|
|
|
let model = options.model;
|
|
let modelFallbackMessage: string | undefined;
|
|
|
|
// If session has data, try to restore model from it
|
|
if (!model && hasExistingSession && existingSession.model) {
|
|
const restoredModel = modelRegistry.find(
|
|
existingSession.model.provider,
|
|
existingSession.model.modelId,
|
|
);
|
|
if (restoredModel && (await modelRegistry.getApiKey(restoredModel))) {
|
|
model = restoredModel;
|
|
}
|
|
if (!model) {
|
|
modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`;
|
|
}
|
|
}
|
|
|
|
// If still no model, use findInitialModel (checks settings default, then provider defaults)
|
|
if (!model) {
|
|
const result = await findInitialModel({
|
|
scopedModels: [],
|
|
isContinuing: hasExistingSession,
|
|
defaultProvider: settingsManager.getDefaultProvider(),
|
|
defaultModelId: settingsManager.getDefaultModel(),
|
|
defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(),
|
|
modelRegistry,
|
|
});
|
|
model = result.model;
|
|
if (!model) {
|
|
modelFallbackMessage = `No models available. Use /login or set an API key environment variable. See ${join(getDocsPath(), "providers.md")}. Then use /model to select a model.`;
|
|
} else if (modelFallbackMessage) {
|
|
modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
|
|
}
|
|
}
|
|
|
|
let thinkingLevel = options.thinkingLevel;
|
|
|
|
// If session has data, restore thinking level from it
|
|
if (thinkingLevel === undefined && hasExistingSession) {
|
|
thinkingLevel = hasThinkingEntry
|
|
? (existingSession.thinkingLevel as ThinkingLevel)
|
|
: (settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL);
|
|
}
|
|
|
|
// Fall back to settings default
|
|
if (thinkingLevel === undefined) {
|
|
thinkingLevel =
|
|
settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;
|
|
}
|
|
|
|
// Clamp to model capabilities
|
|
if (!model || !model.reasoning) {
|
|
thinkingLevel = "off";
|
|
}
|
|
|
|
const defaultActiveToolNames: ToolName[] = [...defaultCodingToolNames];
|
|
const initialActiveToolNames: ToolName[] = options.tools
|
|
? options.tools
|
|
.map((t) => t.name)
|
|
.filter((n): n is ToolName => n in allTools)
|
|
: defaultActiveToolNames;
|
|
|
|
let agent: Agent;
|
|
|
|
// Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth)
|
|
const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => {
|
|
const converted = convertToLlm(messages);
|
|
// Check setting dynamically so mid-session changes take effect
|
|
if (!settingsManager.getBlockImages()) {
|
|
return converted;
|
|
}
|
|
// Filter out ImageContent from all messages, replacing with text placeholder
|
|
return converted.map((msg) => {
|
|
if (msg.role === "user" || msg.role === "toolResult") {
|
|
const content = msg.content;
|
|
if (Array.isArray(content)) {
|
|
const hasImages = content.some((c) => c.type === "image");
|
|
if (hasImages) {
|
|
const filteredContent = content
|
|
.map((c) =>
|
|
c.type === "image"
|
|
? {
|
|
type: "text" as const,
|
|
text: "Image reading is disabled.",
|
|
}
|
|
: c,
|
|
)
|
|
.filter(
|
|
(c, i, arr) =>
|
|
// Dedupe consecutive "Image reading is disabled." texts
|
|
!(
|
|
c.type === "text" &&
|
|
c.text === "Image reading is disabled." &&
|
|
i > 0 &&
|
|
arr[i - 1].type === "text" &&
|
|
(arr[i - 1] as { type: "text"; text: string }).text ===
|
|
"Image reading is disabled."
|
|
),
|
|
);
|
|
return { ...msg, content: filteredContent };
|
|
}
|
|
}
|
|
}
|
|
return msg;
|
|
});
|
|
};
|
|
|
|
const extensionRunnerRef: { current?: ExtensionRunner } = {};
|
|
const sessionRef: { current?: AgentSession } = {};
|
|
|
|
agent = new Agent({
|
|
initialState: {
|
|
systemPrompt: "",
|
|
model,
|
|
thinkingLevel,
|
|
tools: [],
|
|
},
|
|
convertToLlm: convertToLlmWithBlockImages,
|
|
sessionId: sessionManager.getSessionId(),
|
|
transformContext: async (messages) => {
|
|
const currentSession = sessionRef.current;
|
|
let transformedMessages = messages;
|
|
if (currentSession) {
|
|
transformedMessages =
|
|
await currentSession.transformRuntimeContext(transformedMessages);
|
|
}
|
|
const runner = extensionRunnerRef.current;
|
|
if (!runner) return transformedMessages;
|
|
return runner.emitContext(transformedMessages);
|
|
},
|
|
steeringMode: settingsManager.getSteeringMode(),
|
|
followUpMode: settingsManager.getFollowUpMode(),
|
|
transport: settingsManager.getTransport(),
|
|
thinkingBudgets: settingsManager.getThinkingBudgets(),
|
|
maxRetryDelayMs: settingsManager.getRetrySettings().maxDelayMs,
|
|
getApiKey: async (provider) => {
|
|
// Use the provider argument from the in-flight request;
|
|
// agent.state.model may already be switched mid-turn.
|
|
const resolvedProvider = provider || agent.state.model?.provider;
|
|
if (!resolvedProvider) {
|
|
throw new Error("No model selected");
|
|
}
|
|
const key = await modelRegistry.getApiKeyForProvider(resolvedProvider);
|
|
if (!key) {
|
|
const model = agent.state.model;
|
|
const isOAuth = model && modelRegistry.isUsingOAuth(model);
|
|
if (isOAuth) {
|
|
throw new Error(
|
|
`Authentication failed for "${resolvedProvider}". ` +
|
|
`Credentials may have expired or network is unavailable. ` +
|
|
`Run '/login ${resolvedProvider}' to re-authenticate.`,
|
|
);
|
|
}
|
|
throw new Error(
|
|
`No API key found for "${resolvedProvider}". ` +
|
|
`Set an API key environment variable or run '/login ${resolvedProvider}'.`,
|
|
);
|
|
}
|
|
return key;
|
|
},
|
|
});
|
|
|
|
// Restore messages if session has existing data
|
|
if (hasExistingSession) {
|
|
agent.replaceMessages(existingSession.messages);
|
|
if (!hasThinkingEntry) {
|
|
sessionManager.appendThinkingLevelChange(thinkingLevel);
|
|
}
|
|
} else {
|
|
// Save initial model and thinking level for new sessions so they can be restored on resume
|
|
if (model) {
|
|
sessionManager.appendModelChange(model.provider, model.id);
|
|
}
|
|
sessionManager.appendThinkingLevelChange(thinkingLevel);
|
|
}
|
|
|
|
const session = new AgentSession({
|
|
agent,
|
|
sessionManager,
|
|
settingsManager,
|
|
cwd,
|
|
scopedModels: options.scopedModels,
|
|
resourceLoader,
|
|
customTools: options.customTools,
|
|
modelRegistry,
|
|
initialActiveToolNames,
|
|
extensionRunnerRef,
|
|
});
|
|
sessionRef.current = session;
|
|
const extensionsResult = resourceLoader.getExtensions();
|
|
|
|
return {
|
|
session,
|
|
extensionsResult,
|
|
modelFallbackMessage,
|
|
};
|
|
}
|