Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Required
BROWSEROS_CONFIG_URL=
ANTHROPIC_API_KEY=

# Server Ports
Expand Down
102 changes: 67 additions & 35 deletions packages/agent/src/agent/BaseAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
* Copyright 2025 BrowserOS
*/

import { logger } from '@browseros/common'
import type { AgentConfig, AgentMetadata } from './types.js'
import type { FormattedEvent } from '../utils/EventFormatter.js'
import {logger} from '@browseros/common';
import type {AgentConfig, AgentMetadata} from './types.js';
import type {FormattedEvent} from '../utils/EventFormatter.js';

/**
* Generic default system prompt for agents
*
* Minimal prompt - agents should override with their own specific prompts
*/
export const DEFAULT_SYSTEM_PROMPT = `You are a browser automation agent.`
export const DEFAULT_SYSTEM_PROMPT = `You are a browser automation agent.`;

/**
* Generic default configuration values
Expand All @@ -24,8 +24,8 @@ export const DEFAULT_CONFIG = {
maxThinkingTokens: 10000,
systemPrompt: DEFAULT_SYSTEM_PROMPT,
mcpServers: {},
permissionMode: 'bypassPermissions' as const
}
permissionMode: 'bypassPermissions' as const,
};

/**
* BaseAgent - Abstract base class for all agent implementations
Expand Down Expand Up @@ -56,26 +56,40 @@ export const DEFAULT_CONFIG = {
* }
*/
export abstract class BaseAgent {
protected config: Required<AgentConfig>
protected metadata: AgentMetadata
protected executionStartTime: number = 0
protected config: Required<AgentConfig>;
protected metadata: AgentMetadata;
protected executionStartTime: number = 0;
protected initialized: boolean = false;

constructor(
agentType: string,
config: AgentConfig,
agentDefaults?: Partial<AgentConfig>
agentDefaults?: Partial<AgentConfig>,
) {
// Merge config with agent-specific defaults, then with base defaults
this.config = {
apiKey: config.apiKey,
cwd: config.cwd,
maxTurns: config.maxTurns ?? agentDefaults?.maxTurns ?? DEFAULT_CONFIG.maxTurns,
maxThinkingTokens: config.maxThinkingTokens ?? agentDefaults?.maxThinkingTokens ?? DEFAULT_CONFIG.maxThinkingTokens,
systemPrompt: config.systemPrompt ?? agentDefaults?.systemPrompt ?? DEFAULT_CONFIG.systemPrompt,
mcpServers: config.mcpServers ?? agentDefaults?.mcpServers ?? DEFAULT_CONFIG.mcpServers,
permissionMode: config.permissionMode ?? agentDefaults?.permissionMode ?? DEFAULT_CONFIG.permissionMode,
customOptions: config.customOptions ?? agentDefaults?.customOptions ?? {}
}
maxTurns:
config.maxTurns ?? agentDefaults?.maxTurns ?? DEFAULT_CONFIG.maxTurns,
maxThinkingTokens:
config.maxThinkingTokens ??
agentDefaults?.maxThinkingTokens ??
DEFAULT_CONFIG.maxThinkingTokens,
systemPrompt:
config.systemPrompt ??
agentDefaults?.systemPrompt ??
DEFAULT_CONFIG.systemPrompt,
mcpServers:
config.mcpServers ??
agentDefaults?.mcpServers ??
DEFAULT_CONFIG.mcpServers,
permissionMode:
config.permissionMode ??
agentDefaults?.permissionMode ??
DEFAULT_CONFIG.permissionMode,
customOptions: config.customOptions ?? agentDefaults?.customOptions ?? {},
};

// Initialize metadata
this.metadata = {
Expand All @@ -84,94 +98,112 @@ export abstract class BaseAgent {
totalDuration: 0,
lastEventTime: Date.now(),
toolsExecuted: 0,
state: 'idle'
}
state: 'idle',
};

logger.debug(`🤖 ${agentType} agent created`, {
agentType,
cwd: this.config.cwd,
maxTurns: this.config.maxTurns,
maxThinkingTokens: this.config.maxThinkingTokens,
usingDefaultMcp: !config.mcpServers,
usingDefaultPrompt: !config.systemPrompt
})
usingDefaultPrompt: !config.systemPrompt,
});
}

/**
* Async initialization for agents that need it
* Subclasses can override for async setup (e.g., fetching config)
*/
async init(): Promise<void> {
this.initialized = true;
}

/**
* Execute a task and stream events
* Must be implemented by concrete agent classes
*/
abstract execute(message: string): AsyncGenerator<FormattedEvent>
// FIXME: make it handle init if not initialized
abstract execute(message: string): AsyncGenerator<FormattedEvent>;

/**
* Cleanup agent resources
* Must be implemented by concrete agent classes
*/
abstract destroy(): Promise<void>
abstract destroy(): Promise<void>;

/**
* Get current agent metadata
*/
getMetadata(): AgentMetadata {
return { ...this.metadata }
return {...this.metadata};
}

/**
* Helper: Start execution tracking
*/
protected startExecution(): void {
this.metadata.state = 'executing'
this.executionStartTime = Date.now()
this.metadata.state = 'executing';
this.executionStartTime = Date.now();
}

/**
* Helper: Complete execution tracking
*/
protected completeExecution(): void {
this.metadata.state = 'idle'
this.metadata.totalDuration += Date.now() - this.executionStartTime
this.metadata.state = 'idle';
this.metadata.totalDuration += Date.now() - this.executionStartTime;
}

/**
* Helper: Mark execution error
*/
protected errorExecution(error: Error | string): void {
this.metadata.state = 'error'
this.metadata.error = error instanceof Error ? error.message : error
this.metadata.state = 'error';
this.metadata.error = error instanceof Error ? error.message : error;
}

/**
* Helper: Update last event time
*/
protected updateEventTime(): void {
this.metadata.lastEventTime = Date.now()
this.metadata.lastEventTime = Date.now();
}

/**
* Helper: Increment tool execution count
*/
protected updateToolsExecuted(count: number = 1): void {
this.metadata.toolsExecuted += count
this.metadata.toolsExecuted += count;
}

/**
* Helper: Update turn count
*/
protected updateTurns(turns: number): void {
this.metadata.turns = turns
this.metadata.turns = turns;
}

/**
* Helper: Check if agent is destroyed
*/
protected isDestroyed(): boolean {
return this.metadata.state === 'destroyed'
return this.metadata.state === 'destroyed';
}

/**
* Helper: Mark agent as destroyed
*/
protected markDestroyed(): void {
this.metadata.state = 'destroyed'
this.metadata.state = 'destroyed';
}

/**
* Helper: Ensure agent is initialized
*/
protected ensureInitialized(): void {
if (!this.initialized) {
throw new Error('Agent not initialized. Call init() before execute()');
}
}
}
54 changes: 51 additions & 3 deletions packages/agent/src/agent/ClaudeSDKAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { query } from '@anthropic-ai/claude-agent-sdk'
import { EventFormatter, FormattedEvent } from '../utils/EventFormatter.js'
import { logger } from '@browseros/common'
import { logger, fetchBrowserOSConfig, type BrowserOSConfig } from '@browseros/common'
import type { AgentConfig } from './types.js'
import { BaseAgent } from './BaseAgent.js'
import { CLAUDE_SDK_SYSTEM_PROMPT } from './ClaudeSDKAgent.prompt.js'
Expand Down Expand Up @@ -37,6 +37,7 @@ const CLAUDE_SDK_DEFAULTS = {
*/
export class ClaudeSDKAgent extends BaseAgent {
private abortController: AbortController | null = null
private gatewayConfig: BrowserOSConfig | null = null

constructor(config: AgentConfig, controllerBridge: ControllerBridge) {
logger.info('🔧 Using shared ControllerBridge for controller connection')
Expand All @@ -60,21 +61,63 @@ export class ClaudeSDKAgent extends BaseAgent {
logger.info('✅ ClaudeSDKAgent initialized with shared ControllerBridge')
}

/**
* Initialize agent - fetch config from BrowserOS Config URL if configured
* Falls back to ANTHROPIC_API_KEY env var if config URL not set or fails
*/
override async init(): Promise<void> {
const configUrl = process.env.BROWSEROS_CONFIG_URL

if (configUrl) {
logger.info('🌐 Fetching config from BrowserOS Config URL', { configUrl })

try {
this.gatewayConfig = await fetchBrowserOSConfig(configUrl)
this.config.apiKey = this.gatewayConfig.apiKey

logger.info('✅ Using API key from BrowserOS Config URL', {
model: this.gatewayConfig.model
})

await super.init()
return
} catch (error) {
logger.warn('⚠️ Failed to fetch from config URL, falling back to ANTHROPIC_API_KEY', {
error: error instanceof Error ? error.message : String(error)
})
}
}

const envApiKey = process.env.ANTHROPIC_API_KEY
if (envApiKey) {
this.config.apiKey = envApiKey
logger.info('✅ Using API key from ANTHROPIC_API_KEY env var')
await super.init()
return
}

throw new Error(
'No API key found. Set either BROWSEROS_CONFIG_URL or ANTHROPIC_API_KEY'
)
}

/**
* Execute a task using Claude SDK and stream formatted events
*
* @param message - User's natural language request
* @yields FormattedEvent instances
*/
async *execute(message: string): AsyncGenerator<FormattedEvent> {
// Start execution tracking
if (!this.initialized) {
await this.init()
}

this.startExecution()
this.abortController = new AbortController()

logger.info('🤖 ClaudeSDKAgent executing', { message: message.substring(0, 100) })

try {
// Build SDK options with AbortController
const options: any = {
apiKey: this.config.apiKey,
maxTurns: this.config.maxTurns,
Expand All @@ -86,6 +129,11 @@ export class ClaudeSDKAgent extends BaseAgent {
abortController: this.abortController
}

if (this.gatewayConfig?.model) {
options.model = this.gatewayConfig.model
logger.debug('Using model from gateway', { model: this.gatewayConfig.model })
}

// Call Claude SDK
const iterator = query({ prompt: message, options })[Symbol.asyncIterator]()

Expand Down
49 changes: 49 additions & 0 deletions packages/common/src/gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 BrowserOS
*/

import {logger} from './logger.js';

export interface BrowserOSConfig {
model: string;
apiKey: string;
}

export async function fetchBrowserOSConfig(
configUrl: string,
): Promise<BrowserOSConfig> {
logger.debug('Fetching BrowserOS config', {configUrl});

try {
const response = await fetch(configUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
Comment on lines +19 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: fetch() call has no timeout or retry logic. If the config URL is slow or unresponsive, this will hang indefinitely and block server startup.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/common/src/gateway.ts
Line: 19:24

Comment:
**logic:** fetch() call has no timeout or retry logic. If the config URL is slow or unresponsive, this will hang indefinitely and block server startup.

How can I resolve this? If you propose a fix, please make it concise.


if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Failed to fetch config: ${response.status} ${response.statusText} - ${errorText}`,
);
}

const config = (await response.json()) as BrowserOSConfig;

if (!config.model || !config.apiKey) {
throw new Error('Invalid config response: missing model or apiKey');
}
Comment on lines +33 to +37
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Type assertion bypasses validation. The response could contain unexpected data types (e.g., model: 123 instead of string) which would pass this check but fail later.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/common/src/gateway.ts
Line: 33:37

Comment:
**logic:** Type assertion bypasses validation. The response could contain unexpected data types (e.g., `model: 123` instead of string) which would pass this check but fail later.

How can I resolve this? If you propose a fix, please make it concise.


logger.info('✅ BrowserOS config fetched');

return config;
} catch (error) {
logger.error('❌ Failed to fetch BrowserOS config', {
configUrl,
error: error instanceof Error ? error.message : String(error),
});
Comment on lines +43 to +46
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Error log includes the full configUrl which may contain sensitive information like API keys or tokens in query parameters.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/common/src/gateway.ts
Line: 43:46

Comment:
**logic:** Error log includes the full `configUrl` which may contain sensitive information like API keys or tokens in query parameters.

How can I resolve this? If you propose a fix, please make it concise.

throw error;
}
}
2 changes: 2 additions & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {McpContext} from './McpContext.js';
export {Mutex} from './Mutex.js';
export {logger} from './logger.js';
export {metrics} from './metrics.js';
export {fetchBrowserOSConfig} from './gateway.js';

// Utils exports
export * from './utils/index.js';
Expand All @@ -21,3 +22,4 @@ export type {
TextSnapshot,
} from './McpContext.js';
export type {TraceResult} from './types.js';
export type {BrowserOSConfig} from './gateway.js';
9 changes: 1 addition & 8 deletions packages/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,16 +173,9 @@ async function startAgentServer(
ports: ReturnType<typeof parseArguments>,
controllerBridge: ControllerBridge,
): Promise<any> {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
logger.error('[Agent Server] ANTHROPIC_API_KEY is required');
logger.error('Please set ANTHROPIC_API_KEY in .env file');
process.exit(1);
}

const agentConfig: AgentServerConfig = {
port: ports.agentPort,
apiKey,
apiKey: process.env.ANTHROPIC_API_KEY || '',
cwd: process.cwd(),
maxSessions: parseInt(process.env.MAX_SESSIONS || '5'),
idleTimeoutMs: parseInt(process.env.SESSION_IDLE_TIMEOUT_MS || '90000'),
Expand Down
Loading