diff --git a/packages/tcloud-agent/src/agent-runner.ts b/packages/tcloud-agent/src/agent-runner.ts index 71df417..efe660a 100644 --- a/packages/tcloud-agent/src/agent-runner.ts +++ b/packages/tcloud-agent/src/agent-runner.ts @@ -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) ───────── // @@ -254,6 +254,22 @@ export interface SandboxSdkTransportOptions { timeoutMs?: number } +export type RouterChatClient = Pick + +export interface RouterChatTransportOptions extends Omit { + /** + * 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, @@ -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 { + 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 { + 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 = {}, @@ -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 @@ -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++ @@ -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, diff --git a/packages/tcloud-agent/src/index.ts b/packages/tcloud-agent/src/index.ts index 2f1337c..95689d2 100644 --- a/packages/tcloud-agent/src/index.ts +++ b/packages/tcloud-agent/src/index.ts @@ -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' diff --git a/packages/tcloud-agent/tests/agent-runner.test.ts b/packages/tcloud-agent/tests/agent-runner.test.ts index 2a9c164..966c7bd 100644 --- a/packages/tcloud-agent/tests/agent-runner.test.ts +++ b/packages/tcloud-agent/tests/agent-runner.test.ts @@ -6,6 +6,7 @@ import { agent, localCliBridgeTransport, routerBridgeTransport, + routerChatTransport, sandboxSdkTransport, type AgentEvent, type AgentRunCriterion, @@ -87,6 +88,36 @@ function makeFakeClient(responses: ResponseSpec[]) { return { client, calls } } +function makeFakeChatClient(responses: ResponseSpec[]) { + const chats: Array> = [] + 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) { + chats.push({ ...opts, __mode: 'chat' }) + const r = next() + if (r instanceof Error) throw r + return typeof r === 'function' ? r() : r + }, + async *chatStream(opts: Record) { + 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): Promise { const out: AgentEvent[] = [] for await (const ev of iter) out.push(ev) @@ -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({