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
153 changes: 143 additions & 10 deletions packages/tcloud-agent/src/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
*/

import type { AgentProfile, PromptOptions, PromptResult, SandboxEvent, SandboxInstance } from '@tangle-network/sandbox'
import { TCloudClient, type ChatCompletion, type ChatCompletionChunk, type ChatMessage } from '@tangle-network/tcloud'
import { TCloudClient, type ChatCompletion, type ChatCompletionChunk, type ChatMessage, type ChatOptions } from '@tangle-network/tcloud'

// ── Part types (wrappers over the sandbox SDK session-gateway shape) ─────────
//
Expand Down Expand Up @@ -254,6 +254,22 @@ export interface SandboxSdkTransportOptions {
timeoutMs?: number
}

export type RouterChatClient = Pick<TCloudClient, 'chat' | 'chatStream'>

export interface RouterChatTransportOptions extends Omit<ChatOptions, 'messages' | 'model' | 'stream' | 'sandbox' | 'bridge'> {
/**
* Default model for inline profiles that do not set `profile.model.default`.
* A string profile is treated as the model id.
*/
model?: string
/**
* Direct router agents cannot honor sandbox-only fields. Leave this false
* for evals/analysts/judges; set true only when intentionally discarding
* sandbox capabilities.
*/
allowSandboxProfileFields?: boolean
}

class BridgeAgentSessionTransport implements AgentSessionTransport {
constructor(
private readonly client: BridgeClient,
Expand Down Expand Up @@ -319,10 +335,82 @@ class SandboxSdkAgentSessionTransport implements AgentSessionTransport {
}
}

class RouterChatAgentSessionTransport implements AgentSessionTransport {
constructor(
private readonly client: RouterChatClient,
private readonly defaults: RouterChatTransportOptions = {},
) {}

start(input: AgentSessionStart): AgentSession {
const profile = routerChatProfile(input.profile, this.defaults)
return new RouterChatAgentSession(this.client, profile, this.defaults, input.resume)
}
}

class RouterChatAgentSession implements AgentSession {
private readonly history: ChatMessage[]
readonly id?: string

constructor(
private readonly client: RouterChatClient,
private readonly profile: RouterChatProfile,
private readonly defaults: RouterChatTransportOptions,
id?: string,
) {
this.id = id
this.history = profile.systemPrompt ? [{ role: 'system', content: profile.systemPrompt }] : []
}

async chat(options: AgentSessionChatOptions): Promise<ChatCompletion> {
const messages = this.prepareMessages(options)
const completion = await this.client.chat(this.chatOptions(messages))
this.appendAssistant(completion.choices?.[0]?.message?.content ?? '')
return completion
}

async *chatStream(options: AgentSessionChatOptions): AsyncIterable<ChatCompletionChunk> {
const messages = this.prepareMessages(options)
let assistant = ''
try {
for await (const chunk of this.client.chatStream(this.chatOptions(messages))) {
assistant += extractDelta(chunk)
yield chunk
}
} finally {
if (assistant) this.appendAssistant(assistant)
}
}

private prepareMessages(options: AgentSessionChatOptions): ChatMessage[] {
if (options.sandbox?.agentProfile || options.sandbox?.sessionId) {
throw new Error('routerChatTransport does not support sandbox agentProfile/sessionId; use routerBridgeTransport or sandboxSdkTransport')
}
this.history.push(...options.messages)
return [...this.history]
}

private chatOptions(messages: ChatMessage[]): ChatOptions {
const { allowSandboxProfileFields: _allow, ...defaults } = this.defaults
return {
...defaults,
...(this.profile.model ? { model: this.profile.model } : {}),
messages,
}
}

private appendAssistant(content: string): void {
this.history.push({ role: 'assistant', content })
}
}

export function routerBridgeTransport(client: BridgeClient, defaults: BridgeTransportDefaults = {}): AgentSessionTransport {
return new BridgeAgentSessionTransport(client, defaults, false)
}

export function routerChatTransport(client: RouterChatClient, defaults: RouterChatTransportOptions = {}): AgentSessionTransport {
return new RouterChatAgentSessionTransport(client, defaults)
}

export function localCliBridgeTransport(
clientOrConfig: BridgeClient | LocalCliBridgeTransportConfig,
defaults: BridgeTransportDefaults = {},
Expand Down Expand Up @@ -395,15 +483,6 @@ export class Agent {
if (!transport) {
throw new Error('Agent requires either agent(client, options) or options.transport')
}
const session = await transport.start({
profile: this.options.profile,
resume: this.options.resume,
unlock: this.options.unlock,
bridgeUrl: this.options.bridgeUrl,
bridgeBearer: this.options.bridgeBearer,
workspace: this.options.workspace,
})

let nextUserTurn = this.options.brief

let iteration = 0
Expand All @@ -424,6 +503,23 @@ export class Agent {
...(extra.error != null ? { error: extra.error } : {}),
})

let session: AgentSession
try {
session = await transport.start({
profile: this.options.profile,
resume: this.options.resume,
unlock: this.options.unlock,
bridgeUrl: this.options.bridgeUrl,
bridgeBearer: this.options.bridgeBearer,
workspace: this.options.workspace,
})
} catch (err) {
yield buildVerdict('error', {
error: err instanceof Error ? err.message : String(err),
})
return
}

while (iteration < maxIter) {
iteration++

Expand Down Expand Up @@ -569,6 +665,43 @@ function isLocalCliBridgeConfig(value: BridgeClient | LocalCliBridgeTransportCon
return typeof (value as LocalCliBridgeTransportConfig).url === 'string'
}

interface RouterChatProfile {
model?: string
systemPrompt?: string
}

function routerChatProfile(profile: AgentProfile | string, options: RouterChatTransportOptions): RouterChatProfile {
if (typeof profile === 'string') return { model: profile }
const unsupported = unsupportedRouterProfileFields(profile)
if (!options.allowSandboxProfileFields && unsupported.length > 0) {
throw new Error(`routerChatTransport cannot honor sandbox-only profile fields: ${unsupported.join(', ')}`)
}
return {
...(profile.model?.default ?? options.model ? { model: profile.model?.default ?? options.model } : {}),
...(profileSystemPrompt(profile) ? { systemPrompt: profileSystemPrompt(profile) } : {}),
}
}

function profileSystemPrompt(profile: AgentProfile): string | undefined {
const lines = [
profile.prompt?.systemPrompt,
...(profile.prompt?.instructions ?? []),
].filter((line): line is string => typeof line === 'string' && line.trim().length > 0)
return lines.length > 0 ? lines.join('\n') : undefined
}

function unsupportedRouterProfileFields(profile: AgentProfile): string[] {
const keys = ['permissions', 'tools', 'mcp', 'subagents', 'resources', 'hooks', 'modes', 'confidential'] as const
return keys.filter((key) => hasProfileValue(profile[key]))
}

function hasProfileValue(value: unknown): boolean {
if (value == null) return false
if (Array.isArray(value)) return value.length > 0
if (typeof value === 'object') return Object.keys(value).length > 0
return true
}

function withSandbox(
options: AgentSessionChatOptions,
sandbox: AgentSessionChatOptions['sandbox'] | undefined,
Expand Down
3 changes: 3 additions & 0 deletions packages/tcloud-agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,15 @@ export {
type AgentSessionTransport,
type BridgeTransportDefaults,
type LocalCliBridgeTransportConfig,
type RouterChatClient,
type RouterChatTransportOptions,
type SandboxPromptRuntime,
type SandboxSdkTransportOptions,
type TextPart,
type ToolPart,
type ToolState,
routerBridgeTransport,
routerChatTransport,
localCliBridgeTransport,
sandboxSdkTransport,
} from './agent-runner'
96 changes: 96 additions & 0 deletions packages/tcloud-agent/tests/agent-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
agent,
localCliBridgeTransport,
routerBridgeTransport,
routerChatTransport,
sandboxSdkTransport,
type AgentEvent,
type AgentRunCriterion,
Expand Down Expand Up @@ -87,6 +88,36 @@ function makeFakeClient(responses: ResponseSpec[]) {
return { client, calls }
}

function makeFakeChatClient(responses: ResponseSpec[]) {
const chats: Array<Record<string, unknown>> = []
let idx = 0
function next(): ResponseSpec {
const r = responses[Math.min(idx, responses.length - 1)]
idx++
return r
}
return {
chats,
client: {
async chat(opts: Record<string, unknown>) {
chats.push({ ...opts, __mode: 'chat' })
const r = next()
if (r instanceof Error) throw r
return typeof r === 'function' ? r() : r
},
async *chatStream(opts: Record<string, unknown>) {
chats.push({ ...opts, __mode: 'chatStream' })
const r = next()
if (r instanceof Error) throw r
const completion = typeof r === 'function' ? r() : r
for (const chunk of chunksFor(completion)) {
yield chunk
}
},
},
}
}

async function collect(iter: AsyncIterable<AgentEvent>): Promise<AgentEvent[]> {
const out: AgentEvent[] = []
for await (const ev of iter) out.push(ev)
Expand Down Expand Up @@ -259,6 +290,71 @@ describe('Agent.run', () => {
expect(result.verdict).toBe('verified')
})

it('runs inline profiles directly against the router chat surface without sandbox fields', async () => {
const { client, chats } = makeFakeChatClient([makeCompletion('analysis complete')])
const profile = {
name: 'trace-analyst',
prompt: {
systemPrompt: 'You analyze traces.',
instructions: ['Return concise findings.'],
},
model: { default: 'openai/gpt-4o-mini' },
} as AgentProfile

const result = await agent({
transport: routerChatTransport(client as any, { temperature: 0 }),
profile,
brief: 'inspect trace 1',
}).run()

expect(result.verdict).toBe('verified')
expect(chats[0].__mode).toBe('chatStream')
expect(chats[0].model).toBe('openai/gpt-4o-mini')
expect(chats[0].temperature).toBe(0)
expect(chats[0].sandbox).toBeUndefined()
expect(chats[0].messages).toEqual([
{ role: 'system', content: 'You analyze traces.\nReturn concise findings.' },
{ role: 'user', content: 'inspect trace 1' },
])
})

it('keeps router chat history across agent-loop iterations', async () => {
const { client, chats } = makeFakeChatClient([
makeCompletion('not done'),
makeCompletion('DONE'),
])
const result = await agent({
transport: routerChatTransport(client as any),
profile: 'openai/gpt-4o-mini',
brief: 'finish the task',
criteria: [{ name: 'done', check: (ctx) => ({ ok: ctx.lastMessage.includes('DONE'), reason: 'missing DONE' }) }],
}).run()

expect(result.verdict).toBe('verified')
expect(chats).toHaveLength(2)
expect(chats[1].messages).toEqual([
{ role: 'user', content: 'finish the task' },
{ role: 'assistant', content: 'not done' },
{ role: 'user', content: expect.stringContaining("criterion 'done' failed") },
])
})

it('fails closed when a direct router profile asks for sandbox-only capabilities', async () => {
const { client } = makeFakeChatClient([makeCompletion('unused')])
const result = await agent({
transport: routerChatTransport(client as any),
profile: {
name: 'coder',
prompt: { systemPrompt: 'Edit files.' },
tools: { Bash: true },
} as AgentProfile,
brief: 'ship it',
}).run()

expect(result.verdict).toBe('error')
expect(result.error).toContain('sandbox-only profile fields: tools')
})

it('local cli-bridge transport uses direct sandbox model shape and session id', async () => {
const { client, calls } = makeFakeClient([makeCompletion('ok')])
const result = await agent({
Expand Down