Skip to content
Open
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
14 changes: 13 additions & 1 deletion apps/server/src/agent/ai-sdk-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import {
stepCountIs,
ToolLoopAgent,
type ToolSet,
type UIMessage,
wrapLanguageModel,
} from 'ai'
Expand All @@ -23,6 +24,7 @@
import { buildMcpServerSpecs, createMcpClients } from './mcp-builder'
import { buildSystemPrompt } from './prompt'
import { createLanguageModel } from './provider-factory'
import { createDelegateTaskTool } from './sub-agent'
import { buildBrowserToolSet } from './tool-adapter'
import type { ResolvedAgentConfig } from './types'

Expand Down Expand Up @@ -51,7 +53,7 @@
// Build language model with overflow protection middleware
const rawModel = createLanguageModel(config.resolvedConfig)
const model =
(rawModel as any).specificationVersion === 'v3'

Check warning on line 56 in apps/server/src/agent/ai-sdk-agent.ts

View workflow job for this annotation

GitHub Actions / runner / Biome

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.
? wrapLanguageModel({
model: rawModel as LanguageModelV3,
middleware: createContextOverflowMiddleware(contextWindow),
Expand Down Expand Up @@ -88,7 +90,7 @@
const memoryTools = config.resolvedConfig.chatMode
? {}
: buildMemoryToolSet()
const tools = {
const tools: ToolSet = {
...browserTools,
...externalMcpTools,
...filesystemTools,
Expand Down Expand Up @@ -122,6 +124,16 @@
skillsCatalog,
})

// Add sub-agent delegation tool — exact replica of parent, fresh context
if (!config.resolvedConfig.chatMode) {
tools.delegate_task = createDelegateTaskTool({
model,
instructions,
parentTools: tools,
contextWindow,
})
}

// Configure compaction for context window management
const prepareStep = createCompactionPrepareStep({
contextWindow,
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/agent/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@
options?: BuildSystemPromptOptions,
): string {
if (options?.chatMode) return ''

Check warning on line 359 in apps/server/src/agent/prompt.ts

View workflow job for this annotation

GitHub Actions / runner / Biome

lint/correctness/noUnusedFunctionParameters

This parameter is unused.
let prompt = '<page_context>'

if (options?.isScheduledTask) {
Expand Down Expand Up @@ -412,6 +412,35 @@
</workspace>`
}

// -----------------------------------------------------------------------------
// section: sub-agents
// -----------------------------------------------------------------------------

function getSubAgents(
_exclude: Set<string>,
options?: BuildSystemPromptOptions,
): string {
if (options?.chatMode) return ''

return `<sub_agents>
## Task Delegation

You can delegate focused subtasks to independent sub-agents using \`delegate_task\`. Each sub-agent runs in its own context window with full tool access.

**When to delegate:**
- Research requiring reading many pages (the sub-agent explores, you get a summary)
- Data extraction or scraping tasks across multiple sources
- Deep filesystem exploration or code analysis
- Any subtask that would consume significant context in your main conversation

**How to delegate well:**
- Write clear, self-contained task descriptions
- Include all necessary context: URLs, file paths, search terms, expected output format
- The sub-agent has NO access to your conversation history — include everything it needs
- Do not delegate simple single-step actions (just do them directly)
</sub_agents>`
}

const promptSections: Record<string, PromptSectionFn> = {
intro: getIntro,
'security-boundary': getSecurityBoundary,
Expand All @@ -431,6 +460,7 @@
memory: getMemory,
skills: (_exclude: Set<string>, options?: BuildSystemPromptOptions) =>
options?.skillsCatalog || '',
'sub-agents': getSubAgents,
'security-reminder': getSecurityReminder,
}

Expand Down
87 changes: 87 additions & 0 deletions apps/server/src/agent/sub-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { AGENT_LIMITS } from '@browseros/shared/constants/limits'
import type { LanguageModel } from 'ai'
import { stepCountIs, ToolLoopAgent, type ToolSet, tool } from 'ai'
import { z } from 'zod'
import { logger } from '../lib/logger'
import { createCompactionPrepareStep } from './compaction'

export interface DelegateTaskDeps {
model: LanguageModel
instructions: string
parentTools: ToolSet
contextWindow: number
}

const SUB_AGENT_SUFFIX =
'\n\nIMPORTANT: When you have finished, write a clear summary of your findings ' +
'as your final response. This summary will be returned to the main agent, ' +
'so include all relevant information.'

/**
* Creates the `delegate_task` tool following the AI SDK subagent pattern.
* The sub-agent is an exact replica of the parent agent — same model, same
* instructions, same tools, same compaction — just with a fresh context
* window and a lower step limit.
*
* @see https://ai-sdk.dev/docs/agents/subagents#basic-subagent-without-streaming
*/
export function createDelegateTaskTool(deps: DelegateTaskDeps) {
// Filter out delegate_task to prevent recursive spawning
const { delegate_task: _, ...subAgentTools } = deps.parentTools

// Reuse parent's full instructions + summarization suffix
const instructions = deps.instructions + SUB_AGENT_SUFFIX

// Sub-agent gets its own compaction for context safety
const prepareStep = createCompactionPrepareStep({
contextWindow: deps.contextWindow,
})

// Create the sub-agent once — reused across invocations
const subAgent = new ToolLoopAgent({
model: deps.model,
instructions,
tools: subAgentTools,
stopWhen: [stepCountIs(AGENT_LIMITS.SUB_AGENT_MAX_TURNS)],
prepareStep,
})
Comment on lines +40 to +47
Copy link
Contributor

Choose a reason for hiding this comment

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

Sub-agent instance reused across invocations

The ToolLoopAgent is constructed once at factory-creation time and then reused for every delegate_task call. If ToolLoopAgent maintains any per-instance state — particularly if stepCountIs tracks steps cumulatively across generate() calls rather than resetting per call — the second delegation will inherit leftover step count and may terminate sooner than the intended 15-step limit.

It would be safer to construct the ToolLoopAgent inside execute so each invocation starts from a guaranteed clean state:

execute: async ({ task }, { abortSignal }) => {
  const subAgent = new ToolLoopAgent({
    model: deps.model,
    instructions,
    tools: subAgentTools,
    stopWhen: [stepCountIs(AGENT_LIMITS.SUB_AGENT_MAX_TURNS)],
    prepareStep,
  })
  // ...
}

If the AI SDK guarantees that generate() always resets the step counter, add a brief comment referencing the SDK docs so future readers don't have to investigate this.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/src/agent/sub-agent.ts
Line: 40-47

Comment:
**Sub-agent instance reused across invocations**

The `ToolLoopAgent` is constructed once at factory-creation time and then reused for every `delegate_task` call. If `ToolLoopAgent` maintains any per-instance state — particularly if `stepCountIs` tracks steps cumulatively across `generate()` calls rather than resetting per call — the second delegation will inherit leftover step count and may terminate sooner than the intended 15-step limit.

It would be safer to construct the `ToolLoopAgent` inside `execute` so each invocation starts from a guaranteed clean state:

```typescript
execute: async ({ task }, { abortSignal }) => {
  const subAgent = new ToolLoopAgent({
    model: deps.model,
    instructions,
    tools: subAgentTools,
    stopWhen: [stepCountIs(AGENT_LIMITS.SUB_AGENT_MAX_TURNS)],
    prepareStep,
  })
  // ...
}
```

If the AI SDK guarantees that `generate()` always resets the step counter, add a brief comment referencing the SDK docs so future readers don't have to investigate this.

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


return tool({
description:
'Delegate a focused subtask to an independent sub-agent with its own context window. ' +
'Use for research across many pages, data extraction, deep filesystem exploration, ' +
'or any task that would consume significant context. ' +
'The sub-agent has full tool access and returns a text summary when done.',
inputSchema: z.object({
task: z
.string()
.describe(
'Clear, self-contained description of the subtask. ' +
'Include all necessary context — URLs, file paths, search terms, expected output format.',
),
}),
execute: async ({ task }, { abortSignal }) => {
logger.info('Spawning sub-agent', {
taskPreview: task.slice(0, 120),
})

try {
const result = await subAgent.generate({
prompt: task,
abortSignal,
})

logger.info('Sub-agent completed', {
steps: result.steps.length,
finishReason: result.finishReason,
})

return result.text || 'Sub-agent completed but produced no text output.'
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.error('Sub-agent failed', { error: message })
return `Sub-agent failed: ${message}`
}
},
})
}
1 change: 1 addition & 0 deletions packages/shared/src/constants/limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const RATE_LIMITS = {

export const AGENT_LIMITS = {
MAX_TURNS: 100,
SUB_AGENT_MAX_TURNS: 15,
DEFAULT_CONTEXT_WINDOW: 200_000,

// Compression settings for context compaction heuristics
Expand Down
Loading