chore: checkpoint browser tooling and routing updates

This commit is contained in:
William Valentin
2026-02-17 15:18:37 -08:00
parent 0a4cfda787
commit 9a2f1e2bb2
15 changed files with 499 additions and 67 deletions
+40
View File
@@ -17,6 +17,17 @@ export interface ToolUseEvent {
result?: ToolResult;
}
export interface ToolInventorySnapshot {
sessionId: string;
agent: string;
provider: string;
skill: string;
internalCount: number;
exposedCount: number;
internalBrowser: string[];
exposedBrowser: string[];
}
export interface NativeAgentConfig {
modelClient: ModelClient | ModelRouter;
systemPrompt: string;
@@ -453,6 +464,35 @@ export class NativeAgent {
return this._toolPolicyContext;
}
getToolInventorySnapshot(): ToolInventorySnapshot {
if (!this.toolRegistry) {
return {
sessionId: '-',
agent: '-',
provider: '-',
skill: '-',
internalCount: 0,
exposedCount: 0,
internalBrowser: [],
exposedBrowser: [],
};
}
const internal = this.toolRegistry.filteredList(this._toolPolicyContext).map((tool) => tool.name);
const exposed = this.toolRegistry.filteredToAnthropicFormat(this._toolPolicyContext).map((tool) => tool.name);
const context = this._toolPolicyContext;
return {
sessionId: context?.sessionId ?? '-',
agent: context?.agent ?? '-',
provider: context?.provider ?? '-',
skill: context?.skillName ?? '-',
internalCount: internal.length,
exposedCount: exposed.length,
internalBrowser: internal.filter((name) => name.startsWith('browser.')),
exposedBrowser: exposed.filter((name) => name.startsWith('browser_')),
};
}
setAttachmentCollector(collector: OutboundAttachmentCollector | undefined): void {
this._attachmentCollector = collector;
}
+11 -39
View File
@@ -103,13 +103,6 @@ export function registerTuiCommand(program: Command): void {
const { MinimalTui, startFullscreenTui } = await import('../frontends/tui/index.js');
const { NativeAgent } = await import('../backends/index.js');
const {
ToolRegistry,
ToolExecutor,
ToolPolicy,
allBuiltinTools,
createWebSearchTools,
createProcessTools,
ProcessManager,
createGmailTools,
createGcalTools,
createGdocsTools,
@@ -119,6 +112,8 @@ export function registerTuiCommand(program: Command): void {
createAgentDelegateTool,
} = await import('../tools/index.js');
const { HookEngine } = await import('../hooks/index.js');
const { Lifecycle } = await import('../daemon/lifecycle.js');
const { initTools } = await import('../daemon/tools.js');
const { createModelRouter } = await import('../daemon/index.js');
const { AgentConfigRegistry } = await import('../agents/index.js');
@@ -147,33 +142,8 @@ export function registerTuiCommand(program: Command): void {
const systemPrompt = loadSystemPrompt();
const hookEngine = new HookEngine(config.hooks);
const toolRegistry = new ToolRegistry();
for (const tool of allBuiltinTools) {
toolRegistry.register(tool);
}
// Register web search tools if configured with credentials
if (config.web_search.api_key || config.web_search.endpoint) {
for (const tool of createWebSearchTools({
provider: config.web_search.provider,
apiKey: config.web_search.api_key,
endpoint: config.web_search.endpoint,
maxResults: config.web_search.max_results,
})) {
toolRegistry.register(tool);
}
}
// Initialize process manager and register process tools
const processManager = new ProcessManager({
maxConcurrent: config.process.max_concurrent,
maxRuntimeMinutes: config.process.max_runtime_minutes,
bufferSize: config.process.buffer_size,
});
for (const tool of createProcessTools(processManager)) {
toolRegistry.register(tool);
}
const lifecycle = new Lifecycle();
const { toolRegistry, toolExecutor } = initTools({ config, lifecycle, hookEngine });
// Register Gmail tools if configured
if (config.automation.gmail?.enabled) {
@@ -233,10 +203,6 @@ export function registerTuiCommand(program: Command): void {
}));
}
toolRegistry.setPolicy(new ToolPolicy(config.tools));
const toolExecutor = new ToolExecutor(toolRegistry, hookEngine);
const session = sessionManager.getSession('tui', 'local');
const modelProviderConfigs = buildProviderConfigMap(config);
@@ -262,7 +228,7 @@ export function registerTuiCommand(program: Command): void {
});
const cleanup = () => {
processManager.shutdown();
void lifecycle.shutdown();
sessionStore.close();
};
@@ -292,6 +258,9 @@ export function registerTuiCommand(program: Command): void {
model: config.models.default.model,
agent,
hookEngine,
pairingManager,
localProviders: config.models.local_providers,
currentLocalProvider: config.models.local?.provider,
modelProviderConfigs,
contextThresholdPct: config.compaction.threshold_pct,
onTransfer: transferSessionToTarget,
@@ -331,6 +300,9 @@ export function registerTuiCommand(program: Command): void {
model: config.models.default.model,
agent,
hookEngine,
pairingManager,
localProviders: config.models.local_providers,
currentLocalProvider: config.models.local?.provider,
modelProviderConfigs,
contextThresholdPct: config.compaction.threshold_pct,
onTransfer: transferSessionToTarget,
+15 -13
View File
@@ -253,6 +253,20 @@ export function createMessageRouter(deps: {
} as AgentDelegateDeps));
}
const toolPolicyContext = {
agent: effectiveTier,
provider: effectiveProvider,
sessionId: session.id,
channel,
sender: senderId,
tier: effectiveTier,
autonomyLevel: deps.config.agents.autonomy_level ?? 'standard',
skillName: activeSkillName,
skillPermissions: activeSkill?.manifest.permissions,
allowedSecretScopes: activeSkill?.manifest.permissions?.secrets,
executionEnvironment,
};
const orchestrator = new AgentOrchestrator({
modelRouter: deps.modelRouter,
systemPrompt: effectiveSystemPrompt,
@@ -283,19 +297,7 @@ export function createMessageRouter(deps: {
memoryAutoExtract: deps.config.memory?.auto_extract,
memoryInjectionStrategy: deps.config.memory?.injection_strategy,
memoryMaxInjectionTokens: deps.config.memory?.max_injection_tokens,
toolPolicyContext: {
agent: effectiveTier,
provider: effectiveProvider,
sessionId: session.id,
channel,
sender: senderId,
tier: effectiveTier,
autonomyLevel: deps.config.agents.autonomy_level ?? 'standard',
skillName: activeSkillName,
skillPermissions: activeSkill?.manifest.permissions,
allowedSecretScopes: activeSkill?.manifest.permissions?.secrets,
executionEnvironment,
},
toolPolicyContext,
attachmentCollector: collector,
});
// Resolve the lazy orchestrator reference for agent.delegate
+3 -1
View File
@@ -66,7 +66,7 @@ export function initTools(deps: ToolsDeps): ToolsResult {
}
// Initialize browser manager and register browser tools (if enabled)
const browserToolNames = ['browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval'];
const browserToolNames = ['browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate'];
let browserManager: BrowserManager | undefined;
if (config.browser?.enabled) {
const manager = new BrowserManager({
@@ -108,6 +108,8 @@ export function initTools(deps: ToolsDeps): ToolsResult {
const availableBrowserTools = browserToolNames.filter((name) => allowed.has(name));
if (availableBrowserTools.length === 0) {
console.log('Browser tools are registered but blocked by tool policy (use tools.profile=coding/full or tools.allow).');
} else {
console.log(`Browser tools available after policy: ${availableBrowserTools.join(', ')}`);
}
}
+277 -6
View File
@@ -13,6 +13,9 @@ import type { ModelConfig, ModelProvider } from '../../../config/schema.js';
import { MODEL_PROVIDERS } from '../../../config/schema.js';
import { createClientFromConfig } from '../../../daemon/index.js';
import { estimateMessageTokens, getContextWindow } from '../../../context/tokens.js';
import type { PairingManager } from '../../../channels/pairing.js';
import { loginGitHub, loginOpenAI } from '../../../auth/index.js';
import { OllamaClient, LlamaCppClient } from '../../../models/index.js';
/** Format a tool name like "gmail.list" -> "Gmail: List" */
function formatToolName(name: string): string {
@@ -49,6 +52,9 @@ export interface AppProps {
model: string;
agent?: NativeAgent;
hookEngine?: HookEngine;
pairingManager?: PairingManager;
localProviders?: Record<string, ModelConfig>;
currentLocalProvider?: string;
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
contextThresholdPct?: number;
onTransfer?: (target: string) => string | void;
@@ -63,6 +69,9 @@ export function App({
model,
agent,
hookEngine,
pairingManager,
localProviders,
currentLocalProvider,
modelProviderConfigs,
contextThresholdPct,
onTransfer,
@@ -196,6 +205,55 @@ export function App({
}
});
const pushAssistantMessage = useCallback((content: string) => {
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content })]);
}, [session]);
const getAvailableBackends = useCallback((): string[] => {
const backends: string[] = [];
if (currentLocalProvider) {
backends.push(currentLocalProvider);
}
if (localProviders) {
backends.push(...Object.keys(localProviders));
}
return [...new Set(backends)];
}, [currentLocalProvider, localProviders]);
const createLocalClient = useCallback((cfg: ModelConfig): ModelClient | null => {
if (cfg.provider === 'ollama') {
return new OllamaClient({
model: cfg.model,
host: cfg.endpoint,
});
}
if (cfg.provider === 'llamacpp') {
return new LlamaCppClient({
endpoint: cfg.endpoint ?? 'http://localhost:8080',
model: cfg.model,
authToken: cfg.auth_token,
});
}
return null;
}, []);
const parseDurationToMs = useCallback((value: string): number | null => {
const m = value.match(/^(\d+)([smhd])$/i);
if (!m) {
return null;
}
const n = Number.parseInt(m[1], 10);
if (!Number.isFinite(n) || n <= 0) {
return null;
}
const unit = m[2].toLowerCase();
if (unit === 's') {return n * 1000;}
if (unit === 'm') {return n * 60_000;}
if (unit === 'h') {return n * 3_600_000;}
if (unit === 'd') {return n * 86_400_000;}
return null;
}, []);
const handleSubmit = useCallback(async (value: string) => {
if (confirmation) {
return;
@@ -273,7 +331,12 @@ export function App({
case 'verbose': {
const next = !verbose;
setVerbose(next);
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Verbose mode: ${next ? 'on' : 'off'}` })]);
let content = `Verbose mode: ${next ? 'on' : 'off'}`;
if (next && agent) {
const snapshot = agent.getToolInventorySnapshot();
content += `\n[Agent] tool-inventory session=${snapshot.sessionId} agent=${snapshot.agent} provider=${snapshot.provider} skill=${snapshot.skill} internal=${snapshot.internalCount} exposed=${snapshot.exposedCount} internal_browser=[${snapshot.internalBrowser.join(', ') || 'none'}] exposed_browser=[${snapshot.exposedBrowser.join(', ') || 'none'}]`;
}
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content })]);
return;
}
@@ -480,12 +543,213 @@ export function App({
return;
}
case 'backend':
case 'login':
case 'pair':
case 'elevate':
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `/${command.type} is not supported in fullscreen mode.` })]);
case 'backend': {
if (!modelRouter) {
pushAssistantMessage('Backend switching not available.');
return;
}
if (!command.provider) {
const current = modelRouter.getLocalProviderName() ?? currentLocalProvider ?? 'unknown';
const available = getAvailableBackends();
pushAssistantMessage(`Current local backend: ${current}\nAvailable: ${available.join(', ')}`);
return;
}
const providerConfig = localProviders?.[command.provider];
if (!providerConfig) {
const available = getAvailableBackends();
pushAssistantMessage(`Backend '${command.provider}' not configured.\nAvailable: ${available.join(', ')}`);
return;
}
const client = createLocalClient(providerConfig);
if (!client) {
pushAssistantMessage(`Unsupported backend provider '${providerConfig.provider}'.`);
return;
}
modelRouter.setLocalClient(client, command.provider);
modelRouter.setTier('local');
if (agent) {
agent.setModelTier('local');
}
setCurrentModel(modelRouter.getLabel('local'));
pushAssistantMessage(`Switched backend to ${command.provider}`);
return;
}
case 'login': {
const provider = (command.provider ?? '').trim().toLowerCase();
if (!provider) {
pushAssistantMessage('Usage: /login <provider>\nSupported: github, openai, anthropic, zai');
return;
}
if (provider === 'github') {
pushAssistantMessage('Starting GitHub OAuth device login...');
try {
await loginGitHub((userCode, verificationUri) => {
pushAssistantMessage(`GitHub login required:\nCode: ${userCode}\nURL: ${verificationUri}`);
});
pushAssistantMessage('GitHub login complete. Token stored in ~/.config/flynn/auth.json');
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
pushAssistantMessage(`GitHub login failed: ${msg}`);
}
return;
}
if (provider === 'openai') {
pushAssistantMessage('Starting OpenAI OAuth device login...');
try {
await loginOpenAI((userCode, verificationUri) => {
pushAssistantMessage(`OpenAI login required:\nCode: ${userCode}\nURL: ${verificationUri}`);
});
pushAssistantMessage('OpenAI login complete. Credentials stored in ~/.config/flynn/auth.json');
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
pushAssistantMessage(`OpenAI login failed: ${msg}`);
}
return;
}
if (provider === 'anthropic' || provider === 'zai' || provider === 'zhipuai') {
pushAssistantMessage(
`/${command.type} ${provider} requires key entry, which fullscreen mode does not mask.\nUse minimal mode (pnpm tui) for interactive key setup.`,
);
return;
}
pushAssistantMessage(`Unknown login provider: ${provider}. Supported: github, openai, anthropic, zai`);
return;
}
case 'pair': {
if (!pairingManager) {
pushAssistantMessage('Pairing not enabled. Set pairing.enabled: true in config.');
return;
}
if (command.action === 'generate') {
const code = pairingManager.generateCode(command.args);
const pending = pairingManager.listPendingCodes().find(p => p.code === code);
const expiresIn = pending ? Math.round((pending.expiresAt - Date.now()) / 1000) : '?';
pushAssistantMessage(`Pairing code: ${code}\nExpires in ${expiresIn}s${command.args ? ` (label: ${command.args})` : ''}`);
return;
}
if (command.action === 'revoke') {
const args = (command.args ?? '').trim();
const parts = args.split(/\s+/);
if (parts.length < 2) {
pushAssistantMessage('Usage: /pair revoke <channel> <senderId>');
return;
}
const [channel, senderId] = parts;
const revoked = pairingManager.revokeApproval(channel, senderId);
pushAssistantMessage(revoked ? `Revoked approval for ${channel}:${senderId}` : `No approval found for ${channel}:${senderId}`);
return;
}
const pending = pairingManager.listPendingCodes();
const approved = pairingManager.listApproved();
if (pending.length === 0 && approved.length === 0) {
pushAssistantMessage('No pending codes or approved senders.');
return;
}
const lines: string[] = [];
if (pending.length > 0) {
lines.push('Pending codes:');
for (const p of pending) {
const ttl = Math.max(0, Math.round((p.expiresAt - Date.now()) / 1000));
lines.push(` ${p.code} expires in ${ttl}s${p.label ? ` (label: ${p.label})` : ''}`);
}
}
if (approved.length > 0) {
lines.push('Approved senders:');
for (const a of approved) {
const date = new Date(a.approvedAt).toISOString().slice(0, 16).replace('T', ' ');
lines.push(` ${a.channel}:${a.senderId} since ${date} (code: ${a.codeUsed})`);
}
}
pushAssistantMessage(lines.join('\n'));
return;
}
case 'elevate': {
const untilRaw = session.getConfig('elevation.until_ms');
const reason = session.getConfig('elevation.reason') ?? '';
const id = session.getConfig('elevation.id') ?? '';
const showStatus = () => {
if (!untilRaw || !id) {
pushAssistantMessage('Elevated mode: off');
return;
}
const untilMs = Number.parseInt(untilRaw, 10);
if (!Number.isFinite(untilMs) || untilMs <= Date.now()) {
session.deleteConfig('elevation.until_ms');
session.deleteConfig('elevation.reason');
session.deleteConfig('elevation.id');
pushAssistantMessage('Elevated mode: off');
return;
}
const remainingSec = Math.ceil((untilMs - Date.now()) / 1000);
pushAssistantMessage(`Elevated mode: on (${remainingSec}s remaining)${reason ? ` - ${reason}` : ''}`);
};
const raw = (command.args ?? '').trim();
if (!raw) {
showStatus();
return;
}
const parts = raw.split(/\s+/);
const hasYes = parts.includes('--yes') || parts.includes('--confirm');
const filtered = parts.filter((p) => p !== '--yes' && p !== '--confirm');
if (filtered.length === 0) {
pushAssistantMessage('Usage: /elevate <duration> <reason...> --yes | /elevate off --yes');
return;
}
if (filtered[0] === 'off') {
if (!hasYes) {
pushAssistantMessage('Refusing to disable elevation without explicit confirmation. Use: /elevate off --yes');
return;
}
session.deleteConfig('elevation.until_ms');
session.deleteConfig('elevation.reason');
session.deleteConfig('elevation.id');
pushAssistantMessage('Elevated mode: off');
return;
}
if (!hasYes) {
pushAssistantMessage('Refusing to enable elevation without explicit confirmation. Use: /elevate <duration> <reason...> --yes');
return;
}
const ttlMs = parseDurationToMs(filtered[0]);
if (!ttlMs) {
pushAssistantMessage('Invalid duration. Use one of: 30s, 10m, 1h, 1d');
return;
}
const reasonText = filtered.slice(1).join(' ').trim();
const untilMs = Date.now() + ttlMs;
const newId = `${untilMs}`;
session.setConfig('elevation.until_ms', String(untilMs));
session.setConfig('elevation.id', newId);
if (reasonText) {
session.setConfig('elevation.reason', reasonText);
} else {
session.deleteConfig('elevation.reason');
}
pushAssistantMessage(`Elevated mode: on until ${new Date(untilMs).toISOString()}`);
return;
}
case 'message':
break;
@@ -585,6 +849,13 @@ export function App({
messages.length,
tokenUsage.inputTokens,
tokenUsage.outputTokens,
pushAssistantMessage,
getAvailableBackends,
createLocalClient,
parseDurationToMs,
localProviders,
currentLocalProvider,
pairingManager,
modelProviderConfigs,
onTransfer,
]);
+7
View File
@@ -7,6 +7,7 @@ import type { ModelRouter } from '../../models/router.js';
import type { NativeAgent } from '../../backends/native/agent.js';
import type { HookEngine } from '../../hooks/index.js';
import type { ModelConfig, ModelProvider } from '../../config/index.js';
import type { PairingManager } from '../../channels/pairing.js';
export interface FullscreenTuiConfig {
session: ManagedSession;
@@ -16,6 +17,9 @@ export interface FullscreenTuiConfig {
model: string;
agent?: NativeAgent;
hookEngine?: HookEngine;
pairingManager?: PairingManager;
localProviders?: Record<string, ModelConfig>;
currentLocalProvider?: string;
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
contextThresholdPct?: number;
onTransfer?: (target: string) => string | void;
@@ -41,6 +45,9 @@ export async function startFullscreenTui(config: FullscreenTuiConfig): Promise<v
model: config.model,
agent: config.agent,
hookEngine: config.hookEngine,
pairingManager: config.pairingManager,
localProviders: config.localProviders,
currentLocalProvider: config.currentLocalProvider,
modelProviderConfigs: config.modelProviderConfigs,
contextThresholdPct: config.contextThresholdPct,
onTransfer: config.onTransfer,
+8 -1
View File
@@ -468,7 +468,14 @@ export class MinimalTui {
private handleVerboseCommand(): void {
this.verbose = !this.verbose;
console.log(`${colors.gray}Verbose mode:${colors.reset} ${this.verbose ? 'on' : 'off'}\n`);
console.log(`${colors.gray}Verbose mode:${colors.reset} ${this.verbose ? 'on' : 'off'}`);
if (this.verbose && this.config.agent) {
const snapshot = this.config.agent.getToolInventorySnapshot();
console.log(
`[Agent] tool-inventory session=${snapshot.sessionId} agent=${snapshot.agent} provider=${snapshot.provider} skill=${snapshot.skill} internal=${snapshot.internalCount} exposed=${snapshot.exposedCount} internal_browser=[${snapshot.internalBrowser.join(', ') || 'none'}] exposed_browser=[${snapshot.exposedBrowser.join(', ') || 'none'}]`,
);
}
console.log('');
}
private handleQueueCommand(action?: 'show' | 'set' | 'reset', args?: string): void {
+9 -1
View File
@@ -55,7 +55,8 @@ describe('Browser tools', () => {
expect(names).toContain('browser.type');
expect(names).toContain('browser.content');
expect(names).toContain('browser.eval');
expect(names).toHaveLength(6);
expect(names).toContain('browser.evaluate');
expect(names).toHaveLength(7);
});
it('browser.navigate navigates to URL', async () => {
@@ -147,6 +148,13 @@ describe('Browser tools', () => {
expect(result.output).toBe('hello world');
});
it('browser.evaluate aliases browser.eval behavior', async () => {
const tool = getTool('browser.evaluate');
const result = await tool.execute({ expression: '1 + 1' });
expect(result.success).toBe(true);
expect(result.output).toContain('42');
});
it('handles navigation errors gracefully', async () => {
mockGoto.mockRejectedValueOnce(new Error('Navigation failed'));
const tool = getTool('browser.navigate');
+19 -2
View File
@@ -64,6 +64,7 @@ export function createBrowserTools(manager: BrowserManager): Tool[] {
createBrowserTypeTool(manager),
createBrowserContentTool(manager),
createBrowserEvalTool(manager),
createBrowserEvaluateTool(manager),
];
}
@@ -294,9 +295,25 @@ function createBrowserContentTool(manager: BrowserManager): Tool {
}
function createBrowserEvalTool(manager: BrowserManager): Tool {
return createBrowserEvalLikeTool(
manager,
'browser.eval',
'Evaluate JavaScript in the browser page context. Returns the result as a string.',
);
}
function createBrowserEvaluateTool(manager: BrowserManager): Tool {
return createBrowserEvalLikeTool(
manager,
'browser.evaluate',
'Alias of browser.eval for compatibility. Evaluates JavaScript in the browser page context.',
);
}
function createBrowserEvalLikeTool(manager: BrowserManager, name: 'browser.eval' | 'browser.evaluate', description: string): Tool {
return {
name: 'browser.eval',
description: 'Evaluate JavaScript in the browser page context. Returns the result as a string.',
name,
description,
inputSchema: {
type: 'object',
properties: {
+2 -1
View File
@@ -98,6 +98,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'browser.type',
'browser.content',
'browser.eval',
'browser.evaluate',
'agent.delegate',
'agents.list',
]),
@@ -110,7 +111,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
export const TOOL_GROUPS: Record<string, string[]> = {
'group:fs': ['file.read', 'file.write', 'file.edit', 'file.patch', 'file.list'],
'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list', 'screen.capture', 'camera.capture'],
'group:web': ['web.fetch', 'web.search', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval'],
'group:web': ['web.fetch', 'web.search', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate'],
'group:memory': ['memory.read', 'memory.write', 'memory.search'],
'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read'],
'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'],