mirror of
https://github.com/langchain-ai/openwork.git
synced 2026-07-01 20:37:03 -04:00
readme, cleanup logs, bash support
This commit is contained in:
@@ -1,29 +1,23 @@
|
||||
# openwork
|
||||
|
||||
[](https://github.com/langchain-ai/openwork/actions/workflows/ci.yml)
|
||||
[](https://www.npmjs.com/package/openwork)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[![npm][npm-badge]][npm-url] [![License: MIT][license-badge]][license-url]
|
||||
|
||||
A tactical agent interface for [deepagentsjs](https://github.com/langchain-ai/deepagentsjs) - an opinionated harness for building deep agents with filesystem capabilities, planning, and subagent delegation.
|
||||
[npm-badge]: https://img.shields.io/npm/v/openwork.svg
|
||||
[npm-url]: https://www.npmjs.com/package/openwork
|
||||
[license-badge]: https://img.shields.io/badge/License-MIT-yellow.svg
|
||||
[license-url]: https://opensource.org/licenses/MIT
|
||||
|
||||
A desktop interface for [deepagentsjs](https://github.com/langchain-ai/deepagentsjs) — an opinionated harness for building deep agents with filesystem capabilities planning, and subagent delegation.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
> [!CAUTION]
|
||||
> openwork gives AI agents direct access to your filesystem and the ability to execute shell commands. Always review tool calls before approving them, and only run in workspaces you trust.
|
||||
|
||||
- **Chat Interface** - Stream conversations with your AI agent in real-time
|
||||
- **TODO Tracking** - Visual task list showing agent's planning progress
|
||||
- **Filesystem Browser** - See files the agent reads, writes, and edits
|
||||
- **Subagent Monitoring** - Track spawned subagents and their status
|
||||
- **Human-in-the-Loop** - Approve, edit, or reject sensitive tool calls
|
||||
- **Multi-Model Support** - Use Claude, GPT-4, Gemini, or local models
|
||||
- **Thread Persistence** - SQLite-backed conversation history
|
||||
|
||||
## Installation
|
||||
|
||||
### npm (recommended)
|
||||
## Get Started
|
||||
|
||||
```bash
|
||||
# Run directly
|
||||
# Run directly with npx
|
||||
npx openwork
|
||||
|
||||
# Or install globally
|
||||
@@ -31,7 +25,7 @@ npm install -g openwork
|
||||
openwork
|
||||
```
|
||||
|
||||
Requires Node.js 18+. Electron is installed automatically as a dependency.
|
||||
Requires Node.js 18+.
|
||||
|
||||
### From Source
|
||||
|
||||
@@ -41,103 +35,21 @@ cd openwork
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Or configure them in-app via the settings panel.
|
||||
|
||||
## Configuration
|
||||
## Supported Models
|
||||
|
||||
### API Keys
|
||||
|
||||
openwork supports multiple LLM providers. Set your API keys via:
|
||||
|
||||
1. **Environment Variables** (recommended)
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
export GOOGLE_API_KEY="..."
|
||||
```
|
||||
|
||||
2. **In-App Settings** - Click the settings icon and enter your API keys securely.
|
||||
|
||||
### Supported Models
|
||||
|
||||
| Provider | Models |
|
||||
|----------|--------|
|
||||
| Anthropic | Claude Sonnet 4, Claude 3.5 Sonnet, Claude 3.5 Haiku |
|
||||
| OpenAI | GPT-4o, GPT-4o Mini |
|
||||
| Google | Gemini 2.0 Flash |
|
||||
|
||||
## Architecture
|
||||
|
||||
openwork is built with:
|
||||
|
||||
- **Electron** - Cross-platform desktop framework
|
||||
- **React** - UI components with tactical/SCADA-inspired design
|
||||
- **deepagentsjs** - Agent harness with planning, filesystem, and subagents
|
||||
- **LangGraph** - State machine for agent orchestration
|
||||
- **SQLite** - Local persistence for threads and checkpoints
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Electron Main Process │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ IPC Handlers│ │ SQLite │ │ DeepAgentsJS │ │
|
||||
│ │ - agent │ │ - threads │ │ - createAgent │ │
|
||||
│ │ - threads │ │ - runs │ │ - checkpointer │ │
|
||||
│ │ - models │ │ - assists │ │ - middleware │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
IPC Bridge
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Electron Renderer Process │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────┐ ┌─────────────────────┐ ┌───────────────┐ │
|
||||
│ │ Sidebar │ │ Chat Interface │ │ Right Panel │ │
|
||||
│ │ - Threads│ │ - Messages │ │ - TODOs │ │
|
||||
│ │ - Model │ │ - Tool Renderers │ │ - Files │ │
|
||||
│ │ - Config │ │ - Streaming │ │ - Subagents │ │
|
||||
│ └──────────┘ └─────────────────────┘ └───────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
To publish a new release:
|
||||
|
||||
1. Create a git tag: `git tag v0.2.0`
|
||||
2. Push the tag: `git push origin v0.2.0`
|
||||
3. GitHub Actions will:
|
||||
- Build the application
|
||||
- Publish to npm
|
||||
- Create a GitHub release
|
||||
|
||||
## Design System
|
||||
|
||||
openwork uses a tactical/SCADA-inspired design system optimized for:
|
||||
|
||||
- **Information density** - Dense layouts for monitoring agent activity
|
||||
- **Status at a glance** - Color-coded status indicators (nominal, warning, critical)
|
||||
- **Dark mode only** - Reduced eye strain for extended sessions
|
||||
- **Monospace typography** - JetBrains Mono for data and code
|
||||
| Provider | Models |
|
||||
| --------- | ----------------------------------------------------------------- |
|
||||
| Anthropic | Claude Opus 4.5, Claude Sonnet 4.5, Claude Haiku 4.5, Claude Opus 4.1, Claude Sonnet 4 |
|
||||
| OpenAI | GPT-5.2, GPT-5.1, o3, o3 Mini, o4 Mini, o1, GPT-4.1, GPT-4o |
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines.
|
||||
We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
Report bugs via [GitHub Issues](https://github.com/langchain-ai/openwork/issues).
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
MIT — see [LICENSE](LICENSE) for details.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 650 KiB |
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* LocalSandbox: Execute shell commands locally on the host machine.
|
||||
*
|
||||
* Extends FilesystemBackend with command execution capability.
|
||||
* Commands run in the workspace directory with configurable timeout and output limits.
|
||||
*
|
||||
* Security note: This has NO built-in safeguards except for the human-in-the-loop
|
||||
* middleware provided by the agent framework. All command approval should be
|
||||
* handled via HITL configuration.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { FilesystemBackend, type ExecuteResponse, type SandboxBackendProtocol } from 'deepagents'
|
||||
|
||||
/**
|
||||
* Options for LocalSandbox configuration.
|
||||
*/
|
||||
export interface LocalSandboxOptions {
|
||||
/** Root directory for file operations and command execution (default: process.cwd()) */
|
||||
rootDir?: string
|
||||
/** Enable virtual path mode where "/" maps to rootDir (default: false) */
|
||||
virtualMode?: boolean
|
||||
/** Maximum file size in MB for file operations (default: 10) */
|
||||
maxFileSizeMb?: number
|
||||
/** Command timeout in milliseconds (default: 120000 = 2 minutes) */
|
||||
timeout?: number
|
||||
/** Maximum output bytes before truncation (default: 100000 = ~100KB) */
|
||||
maxOutputBytes?: number
|
||||
/** Environment variables to pass to commands (default: process.env) */
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalSandbox backend with shell command execution.
|
||||
*
|
||||
* Extends FilesystemBackend to inherit all file operations (ls, read, write,
|
||||
* edit, glob, grep) and adds execute() for running shell commands locally.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sandbox = new LocalSandbox({
|
||||
* rootDir: '/path/to/workspace',
|
||||
* virtualMode: true,
|
||||
* timeout: 60_000,
|
||||
* });
|
||||
*
|
||||
* const result = await sandbox.execute('npm test');
|
||||
* console.log(result.output);
|
||||
* console.log('Exit code:', result.exitCode);
|
||||
* ```
|
||||
*/
|
||||
export class LocalSandbox extends FilesystemBackend implements SandboxBackendProtocol {
|
||||
/** Unique identifier for this sandbox instance */
|
||||
readonly id: string
|
||||
|
||||
private readonly timeout: number
|
||||
private readonly maxOutputBytes: number
|
||||
private readonly env: Record<string, string>
|
||||
private readonly workingDir: string
|
||||
|
||||
constructor(options: LocalSandboxOptions = {}) {
|
||||
super({
|
||||
rootDir: options.rootDir,
|
||||
virtualMode: options.virtualMode,
|
||||
maxFileSizeMb: options.maxFileSizeMb
|
||||
})
|
||||
|
||||
this.id = `local-sandbox-${randomUUID().slice(0, 8)}`
|
||||
this.timeout = options.timeout ?? 120_000 // 2 minutes default
|
||||
this.maxOutputBytes = options.maxOutputBytes ?? 100_000 // ~100KB default
|
||||
this.env = options.env ?? ({ ...process.env } as Record<string, string>)
|
||||
this.workingDir = options.rootDir ?? process.cwd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a shell command in the workspace directory.
|
||||
*
|
||||
* @param command - Shell command string to execute
|
||||
* @returns ExecuteResponse with combined output, exit code, and truncation flag
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await sandbox.execute('echo "Hello World"');
|
||||
* // result.output: "Hello World\n"
|
||||
* // result.exitCode: 0
|
||||
* // result.truncated: false
|
||||
* ```
|
||||
*/
|
||||
async execute(command: string): Promise<ExecuteResponse> {
|
||||
if (!command || typeof command !== 'string') {
|
||||
return {
|
||||
output: 'Error: Shell tool expects a non-empty command string.',
|
||||
exitCode: 1,
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<ExecuteResponse>((resolve) => {
|
||||
const outputParts: string[] = []
|
||||
let totalBytes = 0
|
||||
let truncated = false
|
||||
let resolved = false
|
||||
|
||||
// Determine shell based on platform
|
||||
const isWindows = process.platform === 'win32'
|
||||
const shell = isWindows ? 'cmd.exe' : '/bin/sh'
|
||||
const shellArgs = isWindows ? ['/c', command] : ['-c', command]
|
||||
|
||||
const proc = spawn(shell, shellArgs, {
|
||||
cwd: this.workingDir,
|
||||
env: this.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
// Handle timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
proc.kill('SIGTERM')
|
||||
// Give it a moment, then force kill
|
||||
setTimeout(() => proc.kill('SIGKILL'), 1000)
|
||||
resolve({
|
||||
output: `Error: Command timed out after ${(this.timeout / 1000).toFixed(1)} seconds.`,
|
||||
exitCode: null,
|
||||
truncated: false
|
||||
})
|
||||
}
|
||||
}, this.timeout)
|
||||
|
||||
// Collect stdout
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
if (truncated) return
|
||||
|
||||
const chunk = data.toString()
|
||||
const newTotal = totalBytes + chunk.length
|
||||
|
||||
if (newTotal > this.maxOutputBytes) {
|
||||
// Truncate to fit within limit
|
||||
const remaining = this.maxOutputBytes - totalBytes
|
||||
if (remaining > 0) {
|
||||
outputParts.push(chunk.slice(0, remaining))
|
||||
}
|
||||
truncated = true
|
||||
totalBytes = this.maxOutputBytes
|
||||
} else {
|
||||
outputParts.push(chunk)
|
||||
totalBytes = newTotal
|
||||
}
|
||||
})
|
||||
|
||||
// Collect stderr with [stderr] prefix per line
|
||||
proc.stderr.on('data', (data: Buffer) => {
|
||||
if (truncated) return
|
||||
|
||||
const chunk = data.toString()
|
||||
// Prefix each line with [stderr]
|
||||
const prefixedLines = chunk
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => `[stderr] ${line}`)
|
||||
.join('\n')
|
||||
|
||||
if (prefixedLines.length === 0) return
|
||||
|
||||
const withNewline = prefixedLines + (chunk.endsWith('\n') ? '\n' : '')
|
||||
const newTotal = totalBytes + withNewline.length
|
||||
|
||||
if (newTotal > this.maxOutputBytes) {
|
||||
const remaining = this.maxOutputBytes - totalBytes
|
||||
if (remaining > 0) {
|
||||
outputParts.push(withNewline.slice(0, remaining))
|
||||
}
|
||||
truncated = true
|
||||
totalBytes = this.maxOutputBytes
|
||||
} else {
|
||||
outputParts.push(withNewline)
|
||||
totalBytes = newTotal
|
||||
}
|
||||
})
|
||||
|
||||
// Handle process exit
|
||||
proc.on('close', (code, signal) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
let output = outputParts.join('')
|
||||
|
||||
// Add truncation notice if needed
|
||||
if (truncated) {
|
||||
output += `\n\n... Output truncated at ${this.maxOutputBytes} bytes.`
|
||||
}
|
||||
|
||||
// If no output, show placeholder
|
||||
if (!output.trim()) {
|
||||
output = '<no output>'
|
||||
}
|
||||
|
||||
resolve({
|
||||
output,
|
||||
exitCode: signal ? null : code,
|
||||
truncated
|
||||
})
|
||||
})
|
||||
|
||||
// Handle spawn errors
|
||||
proc.on('error', (err) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
resolve({
|
||||
output: `Error: Failed to execute command: ${err.message}`,
|
||||
exitCode: 1,
|
||||
truncated: false
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { createDeepAgent, FilesystemBackend } from 'deepagents'
|
||||
import { createDeepAgent } from 'deepagents'
|
||||
import { getDefaultModel } from '../ipc/models'
|
||||
import { getApiKey, getCheckpointDbPath } from '../storage'
|
||||
import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { ChatOpenAI } from '@langchain/openai'
|
||||
import { SqlJsSaver } from '../checkpointer/sqljs-saver'
|
||||
import { LocalSandbox } from './local-sandbox'
|
||||
|
||||
import type * as _lcTypes from 'langchain'
|
||||
import type * as _lcMessages from '@langchain/core/messages'
|
||||
@@ -111,9 +112,11 @@ export async function createAgentRuntime(options: CreateAgentRuntimeOptions) {
|
||||
const checkpointer = await getCheckpointer()
|
||||
console.log('[Runtime] Checkpointer ready')
|
||||
|
||||
const backend = new FilesystemBackend({
|
||||
const backend = new LocalSandbox({
|
||||
rootDir: workspacePath,
|
||||
virtualMode: true
|
||||
virtualMode: true,
|
||||
timeout: 120_000, // 2 minutes
|
||||
maxOutputBytes: 100_000 // ~100KB
|
||||
})
|
||||
|
||||
const systemPrompt = getSystemPrompt(workspacePath)
|
||||
@@ -122,10 +125,12 @@ export async function createAgentRuntime(options: CreateAgentRuntimeOptions) {
|
||||
model,
|
||||
checkpointer,
|
||||
backend,
|
||||
systemPrompt
|
||||
systemPrompt,
|
||||
// Require human approval for all shell commands
|
||||
interruptOn: { execute: true }
|
||||
})
|
||||
|
||||
console.log('[Runtime] Deep agent created with FilesystemBackend at:', workspacePath)
|
||||
console.log('[Runtime] Deep agent created with LocalSandbox at:', workspacePath)
|
||||
return agent
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,22 @@ When delegating to subagents:
|
||||
|
||||
All file paths are virtual paths relative to the workspace root, starting with /.
|
||||
|
||||
### Shell Tool
|
||||
- execute: Run shell commands in the workspace directory
|
||||
|
||||
The execute tool runs commands directly on the user's machine. Use it for:
|
||||
- Running scripts, tests, and builds (npm test, python script.py, make)
|
||||
- Git operations (git status, git diff, git commit)
|
||||
- Installing dependencies (npm install, pip install)
|
||||
- System commands (which, env, pwd)
|
||||
|
||||
**Important:**
|
||||
- All execute commands require user approval before running
|
||||
- Commands run in the workspace root directory
|
||||
- Avoid using shell for file reading (use read_file instead)
|
||||
- Avoid using shell for file searching (use grep/glob instead)
|
||||
- When running non-trivial commands, briefly explain what they do
|
||||
|
||||
## Code References
|
||||
When referencing code, use format: \`file_path:line_number\`
|
||||
|
||||
|
||||
+147
-9
@@ -1,5 +1,6 @@
|
||||
import { IpcMain, BrowserWindow } from 'electron'
|
||||
import { HumanMessage } from '@langchain/core/messages'
|
||||
import { Command } from '@langchain/langgraph'
|
||||
import { createAgentRuntime } from '../agent/runtime'
|
||||
import { getThread } from '../db'
|
||||
import type { HITLDecision } from '../types'
|
||||
@@ -107,26 +108,163 @@ export function registerAgentHandlers(ipcMain: IpcMain): void {
|
||||
}
|
||||
)
|
||||
|
||||
// Handle agent resume (after interrupt approval/rejection via useStream)
|
||||
ipcMain.on(
|
||||
'agent:resume',
|
||||
async (
|
||||
event,
|
||||
{
|
||||
threadId,
|
||||
command
|
||||
}: { threadId: string; command: { resume?: { decision?: string } } }
|
||||
) => {
|
||||
const channel = `agent:stream:${threadId}`
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
console.log('[Agent] Received resume request:', { threadId, command })
|
||||
|
||||
if (!window) {
|
||||
console.error('[Agent] No window found for resume')
|
||||
return
|
||||
}
|
||||
|
||||
// Get workspace path from thread metadata
|
||||
const thread = getThread(threadId)
|
||||
const metadata = thread?.metadata ? JSON.parse(thread.metadata) : {}
|
||||
const workspacePath = metadata.workspacePath as string | undefined
|
||||
|
||||
if (!workspacePath) {
|
||||
window.webContents.send(channel, {
|
||||
type: 'error',
|
||||
error: 'Workspace path is required'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Abort any existing stream before resuming
|
||||
const existingController = activeRuns.get(threadId)
|
||||
if (existingController) {
|
||||
existingController.abort()
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
activeRuns.set(threadId, abortController)
|
||||
|
||||
try {
|
||||
const agent = await createAgentRuntime({ workspacePath })
|
||||
const config = {
|
||||
configurable: { thread_id: threadId },
|
||||
signal: abortController.signal,
|
||||
streamMode: ['messages', 'values'] as const,
|
||||
recursionLimit: 1000
|
||||
}
|
||||
|
||||
// Resume from checkpoint by streaming with Command containing the decision
|
||||
// The HITL middleware expects { decisions: [{ type: 'approve' | 'reject' | 'edit' }] }
|
||||
const decisionType = command?.resume?.decision || 'approve'
|
||||
const resumeValue = { decisions: [{ type: decisionType }] }
|
||||
const stream = await agent.stream(new Command({ resume: resumeValue }), config)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (abortController.signal.aborted) break
|
||||
|
||||
const [mode, data] = chunk as unknown as [string, unknown]
|
||||
window.webContents.send(channel, {
|
||||
type: 'stream',
|
||||
mode,
|
||||
data: JSON.parse(JSON.stringify(data))
|
||||
})
|
||||
}
|
||||
|
||||
window.webContents.send(channel, { type: 'done' })
|
||||
} catch (error) {
|
||||
console.error('[Agent] Resume error:', error)
|
||||
window.webContents.send(channel, {
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
} finally {
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Handle HITL interrupt response
|
||||
ipcMain.handle(
|
||||
ipcMain.on(
|
||||
'agent:interrupt',
|
||||
async (_event, { threadId, decision }: { threadId: string; decision: HITLDecision }) => {
|
||||
async (event, { threadId, decision }: { threadId: string; decision: HITLDecision }) => {
|
||||
const channel = `agent:stream:${threadId}`
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
if (!window) {
|
||||
console.error('[Agent] No window found for interrupt response')
|
||||
return
|
||||
}
|
||||
|
||||
// Get workspace path from thread metadata - REQUIRED
|
||||
const thread = getThread(threadId)
|
||||
const metadata = thread?.metadata ? JSON.parse(thread.metadata) : {}
|
||||
const workspacePath = metadata.workspacePath as string | undefined
|
||||
|
||||
if (!workspacePath) {
|
||||
throw new Error('Workspace path is required')
|
||||
window.webContents.send(channel, {
|
||||
type: 'error',
|
||||
error: 'Workspace path is required'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const agent = await createAgentRuntime({ workspacePath })
|
||||
const config = { configurable: { thread_id: threadId } }
|
||||
|
||||
if (decision.type === 'approve') {
|
||||
await agent.invoke(null, config)
|
||||
// Abort any existing stream before continuing
|
||||
const existingController = activeRuns.get(threadId)
|
||||
if (existingController) {
|
||||
existingController.abort()
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
activeRuns.set(threadId, abortController)
|
||||
|
||||
try {
|
||||
const agent = await createAgentRuntime({ workspacePath })
|
||||
const config = {
|
||||
configurable: { thread_id: threadId },
|
||||
signal: abortController.signal,
|
||||
streamMode: ['messages', 'values'] as const,
|
||||
recursionLimit: 1000
|
||||
}
|
||||
|
||||
if (decision.type === 'approve') {
|
||||
// Resume execution by invoking with null (continues from checkpoint)
|
||||
const stream = await agent.stream(null, config)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (abortController.signal.aborted) break
|
||||
|
||||
const [mode, data] = chunk as unknown as [string, unknown]
|
||||
window.webContents.send(channel, {
|
||||
type: 'stream',
|
||||
mode,
|
||||
data: JSON.parse(JSON.stringify(data))
|
||||
})
|
||||
}
|
||||
|
||||
window.webContents.send(channel, { type: 'done' })
|
||||
} else if (decision.type === 'reject') {
|
||||
// For reject, we need to send a Command with reject decision
|
||||
// For now, just send done - the agent will see no resumption happened
|
||||
window.webContents.send(channel, { type: 'done' })
|
||||
}
|
||||
// edit case handled similarly to approve with modified args
|
||||
} catch (error) {
|
||||
console.error('[Agent] Interrupt error:', error)
|
||||
window.webContents.send(channel, {
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
} finally {
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
// reject and edit handled by Command in future
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Vendored
+5
-1
@@ -22,7 +22,11 @@ interface CustomAPI {
|
||||
command: unknown,
|
||||
onEvent: (event: StreamEvent) => void
|
||||
) => () => void
|
||||
interrupt: (threadId: string, decision: HITLDecision) => Promise<void>
|
||||
interrupt: (
|
||||
threadId: string,
|
||||
decision: HITLDecision,
|
||||
onEvent?: (event: StreamEvent) => void
|
||||
) => () => void
|
||||
cancel: (threadId: string) => Promise<void>
|
||||
}
|
||||
threads: {
|
||||
|
||||
+21
-15
@@ -29,22 +29,16 @@ const api = {
|
||||
message: string,
|
||||
onEvent: (event: StreamEvent) => void
|
||||
): (() => void) => {
|
||||
console.log('[Preload] invoke() called', { threadId, message: message.substring(0, 50) })
|
||||
|
||||
const channel = `agent:stream:${threadId}`
|
||||
|
||||
const handler = (_: unknown, data: StreamEvent): void => {
|
||||
console.log('[Preload] Received event:', data.type)
|
||||
onEvent(data)
|
||||
|
||||
// Clean up listener on terminal events
|
||||
if (data.type === 'done' || data.type === 'error') {
|
||||
ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.on(channel, handler)
|
||||
console.log('[Preload] Sending agent:invoke IPC')
|
||||
ipcRenderer.send('agent:invoke', { threadId, message })
|
||||
|
||||
// Return cleanup function
|
||||
@@ -59,15 +53,10 @@ const api = {
|
||||
command: unknown,
|
||||
onEvent: (event: StreamEvent) => void
|
||||
): (() => void) => {
|
||||
console.log('[Preload] streamAgent() called', { threadId, message: message.substring(0, 50) })
|
||||
|
||||
const channel = `agent:stream:${threadId}`
|
||||
|
||||
const handler = (_: unknown, data: StreamEvent): void => {
|
||||
console.log('[Preload] Received stream event:', data.type)
|
||||
onEvent(data)
|
||||
|
||||
// Clean up listener on terminal events
|
||||
if (data.type === 'done' || data.type === 'error') {
|
||||
ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
@@ -77,10 +66,8 @@ const api = {
|
||||
|
||||
// If we have a command, it might be a resume/retry
|
||||
if (command) {
|
||||
console.log('[Preload] Sending agent:resume IPC')
|
||||
ipcRenderer.send('agent:resume', { threadId, command })
|
||||
} else {
|
||||
console.log('[Preload] Sending agent:invoke IPC')
|
||||
ipcRenderer.send('agent:invoke', { threadId, message })
|
||||
}
|
||||
|
||||
@@ -89,8 +76,27 @@ const api = {
|
||||
ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
},
|
||||
interrupt: (threadId: string, decision: HITLDecision): Promise<void> => {
|
||||
return ipcRenderer.invoke('agent:interrupt', { threadId, decision })
|
||||
interrupt: (
|
||||
threadId: string,
|
||||
decision: HITLDecision,
|
||||
onEvent?: (event: StreamEvent) => void
|
||||
): (() => void) => {
|
||||
const channel = `agent:stream:${threadId}`
|
||||
|
||||
const handler = (_: unknown, data: StreamEvent): void => {
|
||||
onEvent?.(data)
|
||||
if (data.type === 'done' || data.type === 'error') {
|
||||
ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.on(channel, handler)
|
||||
ipcRenderer.send('agent:interrupt', { threadId, decision })
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
},
|
||||
cancel: (threadId: string): Promise<void> => {
|
||||
return ipcRenderer.invoke('agent:cancel', { threadId })
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ModelSwitcher } from './ModelSwitcher'
|
||||
import { Folder } from 'lucide-react'
|
||||
import { WorkspacePicker, selectWorkspaceFolder } from './WorkspacePicker'
|
||||
import { ChatTodos } from './ChatTodos'
|
||||
import { ApprovalDialog } from '@/components/hitl/ApprovalDialog'
|
||||
import { ElectronIPCTransport } from '@/lib/electron-transport'
|
||||
import type { Message } from '@/types'
|
||||
import type { DeepAgent } from '../../../../main/agent/types'
|
||||
@@ -87,13 +86,14 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
// Get error for current thread
|
||||
const threadError = errorByThread[threadId] || null
|
||||
|
||||
// Debug: log pendingApproval state (moved detailed log after displayMessages)
|
||||
|
||||
// Create transport instance (memoized to avoid recreating)
|
||||
const transport = useMemo(() => new ElectronIPCTransport(), [])
|
||||
|
||||
// Handle custom events from the stream
|
||||
const handleCustomEvent = useCallback(
|
||||
(data: CustomEventData): void => {
|
||||
console.log('[ChatContainer] Custom event:', data)
|
||||
switch (data.type) {
|
||||
case 'message':
|
||||
if (data.message) {
|
||||
@@ -116,7 +116,6 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
...(isTool && msg.name && { name: msg.name }),
|
||||
created_at: msg.created_at ? new Date(msg.created_at) : new Date()
|
||||
}
|
||||
console.log('[ChatContainer] Adding message:', storeMsg)
|
||||
appendMessage(storeMsg)
|
||||
}
|
||||
break
|
||||
@@ -178,6 +177,24 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
}
|
||||
})
|
||||
|
||||
// Handle approval decision - use stream.submit with resume command
|
||||
const handleApprovalDecision = useCallback(async (decision: 'approve' | 'reject' | 'edit') => {
|
||||
if (!pendingApproval) return
|
||||
|
||||
// Clear pending approval first
|
||||
setPendingApproval(null)
|
||||
|
||||
// Submit with a resume command - the transport will send to agent:resume
|
||||
try {
|
||||
await stream.submit(
|
||||
null, // No message needed for resume
|
||||
{ command: { resume: { decision } } }
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[ChatContainer] Resume command failed:', err)
|
||||
}
|
||||
}, [pendingApproval, setPendingApproval, stream])
|
||||
|
||||
// Sync todos from stream state
|
||||
const agentValues = stream.values as AgentStreamValues | undefined
|
||||
const streamTodos = agentValues?.todos
|
||||
@@ -386,6 +403,11 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
clearThreadError(threadId)
|
||||
}
|
||||
|
||||
// Clear any pending approval from previous turns
|
||||
if (pendingApproval) {
|
||||
setPendingApproval(null)
|
||||
}
|
||||
|
||||
const message = input.trim()
|
||||
setInput('')
|
||||
|
||||
@@ -480,7 +502,13 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
)}
|
||||
|
||||
{displayMessages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} toolResults={toolResults} />
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
toolResults={toolResults}
|
||||
pendingApproval={pendingApproval}
|
||||
onApprovalDecision={handleApprovalDecision}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Streaming indicator and inline TODOs */}
|
||||
@@ -542,7 +570,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" variant="default" size="icon" disabled={!input.trim()}>
|
||||
<Button type="submit" variant="default" size="icon" disabled={!input.trim()} className="rounded-md">
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -557,8 +585,6 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* HITL Approval Dialog */}
|
||||
{pendingApproval && <ApprovalDialog request={pendingApproval} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { User, Bot } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Message } from '@/types'
|
||||
import type { Message, HITLRequest } from '@/types'
|
||||
import { ToolCallRenderer } from './ToolCallRenderer'
|
||||
import { StreamingMarkdown } from './StreamingMarkdown'
|
||||
|
||||
@@ -13,9 +13,11 @@ interface MessageBubbleProps {
|
||||
message: Message
|
||||
isStreaming?: boolean
|
||||
toolResults?: Map<string, ToolResultInfo>
|
||||
pendingApproval?: HITLRequest | null
|
||||
onApprovalDecision?: (decision: 'approve' | 'reject' | 'edit') => void
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, isStreaming, toolResults }: MessageBubbleProps) {
|
||||
export function MessageBubble({ message, isStreaming, toolResults, pendingApproval, onApprovalDecision }: MessageBubbleProps) {
|
||||
const isUser = message.role === 'user'
|
||||
const isTool = message.role === 'tool'
|
||||
|
||||
@@ -40,7 +42,7 @@ export function MessageBubble({ message, isStreaming, toolResults }: MessageBubb
|
||||
if (!message.content.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
// Use streaming markdown for assistant messages, plain text for user messages
|
||||
if (isUser) {
|
||||
return (
|
||||
@@ -81,7 +83,7 @@ export function MessageBubble({ message, isStreaming, toolResults }: MessageBubb
|
||||
|
||||
const content = renderContent()
|
||||
const hasToolCalls = message.tool_calls && message.tool_calls.length > 0
|
||||
|
||||
|
||||
// Don't render if there's no content and no tool calls
|
||||
if (!content && !hasToolCalls) {
|
||||
return null
|
||||
@@ -106,7 +108,7 @@ export function MessageBubble({ message, isStreaming, toolResults }: MessageBubb
|
||||
)}>
|
||||
{getLabel()}
|
||||
</div>
|
||||
|
||||
|
||||
{content && (
|
||||
<div className={cn(
|
||||
"rounded-sm p-3 overflow-hidden",
|
||||
@@ -119,14 +121,18 @@ export function MessageBubble({ message, isStreaming, toolResults }: MessageBubb
|
||||
{/* Tool calls */}
|
||||
{hasToolCalls && (
|
||||
<div className="space-y-2 overflow-hidden">
|
||||
{message.tool_calls!.map((toolCall) => {
|
||||
{message.tool_calls!.map((toolCall, index) => {
|
||||
const result = toolResults?.get(toolCall.id)
|
||||
const pendingId = pendingApproval?.tool_call?.id
|
||||
const needsApproval = Boolean(pendingId && pendingId === toolCall.id)
|
||||
return (
|
||||
<ToolCallRenderer
|
||||
key={toolCall.id}
|
||||
<ToolCallRenderer
|
||||
key={`${toolCall.id || `tc-${index}`}-${needsApproval ? 'pending' : 'done'}`}
|
||||
toolCall={toolCall}
|
||||
result={result?.content}
|
||||
isError={result?.is_error}
|
||||
needsApproval={needsApproval}
|
||||
onApprovalDecision={needsApproval ? onApprovalDecision : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -24,6 +24,8 @@ interface ToolCallRendererProps {
|
||||
toolCall: ToolCall
|
||||
result?: string | unknown
|
||||
isError?: boolean
|
||||
needsApproval?: boolean
|
||||
onApprovalDecision?: (decision: 'approve' | 'reject' | 'edit') => void
|
||||
}
|
||||
|
||||
const TOOL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
@@ -261,16 +263,34 @@ function TaskDisplay({ args, isExpanded }: { args: Record<string, unknown>; isEx
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRendererProps) {
|
||||
export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onApprovalDecision }: ToolCallRendererProps) {
|
||||
// Defensive: ensure args is always an object
|
||||
const args = toolCall?.args || {}
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
|
||||
// Bail out if no toolCall
|
||||
if (!toolCall) {
|
||||
return null
|
||||
}
|
||||
|
||||
const Icon = TOOL_ICONS[toolCall.name] || Terminal
|
||||
const label = TOOL_LABELS[toolCall.name] || toolCall.name
|
||||
const isPanelSynced = PANEL_SYNCED_TOOLS.has(toolCall.name)
|
||||
|
||||
const handleApprove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onApprovalDecision?.('approve')
|
||||
}
|
||||
|
||||
const handleReject = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onApprovalDecision?.('reject')
|
||||
}
|
||||
|
||||
// Format the main argument for display
|
||||
const getDisplayArg = () => {
|
||||
const args = toolCall.args
|
||||
if (!args) return null
|
||||
if (args.path) return args.path as string
|
||||
if (args.file_path) return args.file_path as string
|
||||
if (args.command) return (args.command as string).slice(0, 50)
|
||||
@@ -284,7 +304,7 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
|
||||
|
||||
// Render formatted content based on tool type
|
||||
const renderFormattedContent = () => {
|
||||
const args = toolCall.args
|
||||
if (!args) return null
|
||||
|
||||
switch (toolCall.name) {
|
||||
case 'write_todos': {
|
||||
@@ -393,7 +413,18 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
|
||||
}
|
||||
|
||||
case 'execute': {
|
||||
// When expanded, output is shown in CommandDisplay - just show status
|
||||
// When collapsed, show the output preview
|
||||
const output = typeof result === 'string' ? result : JSON.stringify(result)
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div className="text-xs text-status-nominal flex items-center gap-1.5">
|
||||
<CheckCircle2 className="size-3" />
|
||||
<span>Command completed</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Collapsed view - show output preview
|
||||
if (output.trim()) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -402,8 +433,8 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
|
||||
<span>Command completed</span>
|
||||
</div>
|
||||
<pre className="text-xs font-mono bg-background rounded-sm p-2 overflow-auto max-h-32 text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{output.slice(0, 1000)}
|
||||
{output.length > 1000 && '...'}
|
||||
{output.slice(0, 500)}
|
||||
{output.length > 500 && '...'}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
@@ -488,7 +519,10 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
|
||||
const hasFormattedDisplay = formattedContent || formattedResult
|
||||
|
||||
return (
|
||||
<div className="rounded-sm border border-border bg-background-elevated overflow-hidden">
|
||||
<div className={cn(
|
||||
"rounded-sm border overflow-hidden",
|
||||
needsApproval ? "border-amber-500/50 bg-amber-500/5" : "border-border bg-background-elevated"
|
||||
)}>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
@@ -500,7 +534,7 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
|
||||
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
|
||||
<Icon className="size-4 text-status-info shrink-0" />
|
||||
<Icon className={cn("size-4 shrink-0", needsApproval ? "text-amber-500" : "text-status-info")} />
|
||||
|
||||
<span className="text-xs font-medium shrink-0">{label}</span>
|
||||
|
||||
@@ -510,21 +544,65 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
|
||||
</span>
|
||||
)}
|
||||
|
||||
{result !== undefined && (
|
||||
{needsApproval && (
|
||||
<Badge variant="warning" className="ml-auto shrink-0">
|
||||
APPROVAL
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{!needsApproval && result === undefined && (
|
||||
<Badge variant="outline" className="ml-auto shrink-0 animate-pulse">
|
||||
RUNNING
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{result !== undefined && !needsApproval && (
|
||||
<Badge variant={isError ? 'critical' : 'nominal'} className="ml-auto shrink-0">
|
||||
{isError ? 'ERROR' : 'OK'}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{isPanelSynced && (
|
||||
{isPanelSynced && !needsApproval && (
|
||||
<Badge variant="outline" className="shrink-0 text-[9px]">
|
||||
SYNCED
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Formatted content (always visible if present) */}
|
||||
{hasFormattedDisplay && !isExpanded && (
|
||||
{/* Approval UI */}
|
||||
{needsApproval ? (
|
||||
<div className="border-t border-amber-500/20 px-3 py-3 space-y-3">
|
||||
{/* Show formatted content (e.g., command preview) */}
|
||||
{formattedContent}
|
||||
|
||||
{/* Arguments */}
|
||||
<div>
|
||||
<div className="text-section-header text-[10px] mb-1">ARGUMENTS</div>
|
||||
<pre className="text-xs font-mono bg-background p-2 rounded-sm overflow-auto max-h-24">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs border border-border rounded-sm hover:bg-background-interactive transition-colors"
|
||||
onClick={handleReject}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs bg-status-nominal text-background rounded-sm hover:bg-status-nominal/90 transition-colors"
|
||||
onClick={handleApprove}
|
||||
>
|
||||
Approve & Run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Formatted content (only visible when collapsed AND has result) */}
|
||||
{hasFormattedDisplay && !isExpanded && !needsApproval && result !== undefined && (
|
||||
<div className="border-t border-border px-3 py-2 space-y-2 overflow-hidden">
|
||||
{formattedContent}
|
||||
{formattedResult}
|
||||
@@ -532,7 +610,7 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
|
||||
)}
|
||||
|
||||
{/* Expanded content - raw details */}
|
||||
{isExpanded && (
|
||||
{isExpanded && !needsApproval && (
|
||||
<div className="border-t border-border px-3 py-2 space-y-2 overflow-hidden">
|
||||
{/* Formatted display first */}
|
||||
{formattedContent}
|
||||
@@ -542,7 +620,7 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
|
||||
<div className="overflow-hidden w-full">
|
||||
<div className="text-section-header mb-1">RAW ARGUMENTS</div>
|
||||
<pre className="text-xs font-mono bg-background p-2 rounded-sm overflow-auto max-h-48 w-full whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(toolCall.args, null, 2)}
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, Check, X, Edit2 } from 'lucide-react'
|
||||
import { Terminal, Check, X, Edit2, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
@@ -12,9 +12,13 @@ interface ApprovalDialogProps {
|
||||
export function ApprovalDialog({ request }: ApprovalDialogProps) {
|
||||
const { respondToApproval } = useAppStore()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editedArgs, setEditedArgs] = useState(
|
||||
JSON.stringify(request.tool_call.args, null, 2)
|
||||
)
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
|
||||
// Defensive: ensure tool_call and args exist
|
||||
const toolCall = request?.tool_call || { id: '', name: 'unknown', args: {} }
|
||||
const args = toolCall.args || {}
|
||||
|
||||
const [editedArgs, setEditedArgs] = useState(JSON.stringify(args, null, 2))
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (isEditing) {
|
||||
@@ -34,79 +38,121 @@ export function ApprovalDialog({ request }: ApprovalDialogProps) {
|
||||
await respondToApproval('reject')
|
||||
}
|
||||
|
||||
const getToolWarning = () => {
|
||||
const name = request.tool_call.name
|
||||
if (name === 'execute') return 'This will execute a shell command'
|
||||
if (name === 'write_file') return 'This will create or overwrite a file'
|
||||
if (name === 'edit_file') return 'This will modify an existing file'
|
||||
// Get a preview of the command for execute tool
|
||||
const getCommandPreview = () => {
|
||||
if (toolCall.name === 'execute' && args.command) {
|
||||
const cmd = String(args.command)
|
||||
return cmd.length > 60 ? cmd.substring(0, 60) + '...' : cmd
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const warning = getToolWarning()
|
||||
const commandPreview = getCommandPreview()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg rounded-sm border border-border bg-card p-6 shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle className="size-5 text-status-warning" />
|
||||
<h2 className="text-lg font-medium">Tool Approval Required</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The agent wants to execute the following action
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="warning">{request.tool_call.name}</Badge>
|
||||
<div className="rounded-md border border-amber-500/50 bg-amber-500/5 overflow-hidden">
|
||||
{/* Header - always visible */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-amber-500/10 transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-center size-8 rounded-md bg-amber-500/20 text-amber-500">
|
||||
<Terminal className="size-4" />
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
{warning && (
|
||||
<div className="mb-4 rounded-sm border border-status-warning/30 bg-status-warning/10 p-3 text-sm text-status-warning">
|
||||
{warning}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Approval Required</span>
|
||||
<Badge variant="warning" className="text-[10px] px-1.5 py-0">
|
||||
{toolCall.name}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-section-header">ARGUMENTS</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
>
|
||||
<Edit2 className="size-3 mr-1" />
|
||||
{isEditing ? 'Cancel Edit' : 'Edit'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedArgs}
|
||||
onChange={(e) => setEditedArgs(e.target.value)}
|
||||
className="w-full h-48 rounded-sm border border-border bg-background p-3 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
) : (
|
||||
<pre className="rounded-sm border border-border bg-background p-3 font-mono text-xs overflow-x-auto max-h-48">
|
||||
{JSON.stringify(request.tool_call.args, null, 2)}
|
||||
</pre>
|
||||
{commandPreview && !isExpanded && (
|
||||
<div className="text-xs text-muted-foreground font-mono truncate mt-0.5">
|
||||
{commandPreview}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleReject}>
|
||||
<X className="size-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button variant="nominal" onClick={handleApprove}>
|
||||
<Check className="size-4 mr-1" />
|
||||
{isEditing ? 'Apply & Approve' : 'Approve'}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isExpanded && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleReject()
|
||||
}}
|
||||
>
|
||||
<X className="size-3 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="nominal"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleApprove()
|
||||
}}
|
||||
>
|
||||
<Check className="size-3 mr-1" />
|
||||
Run
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 border-t border-amber-500/20">
|
||||
{/* Arguments */}
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-section-header text-[10px]">ARGUMENTS</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
>
|
||||
<Edit2 className="size-3 mr-1" />
|
||||
{isEditing ? 'Cancel' : 'Edit'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedArgs}
|
||||
onChange={(e) => setEditedArgs(e.target.value)}
|
||||
className="w-full h-32 rounded-sm border border-border bg-background p-2 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
) : (
|
||||
<pre className="rounded-sm border border-border bg-background p-2 font-mono text-xs overflow-x-auto max-h-32">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 mt-3">
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={handleReject}>
|
||||
<X className="size-3.5 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button variant="nominal" size="sm" className="h-8" onClick={handleApprove}>
|
||||
<Check className="size-3.5 mr-1" />
|
||||
{isEditing ? 'Apply & Run' : 'Run'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,13 @@ interface AccumulatedToolCall {
|
||||
args: string // Accumulated JSON string
|
||||
}
|
||||
|
||||
// Completed tool call with parsed args
|
||||
interface CompletedToolCall {
|
||||
id: string
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom transport for useStream that uses Electron IPC instead of HTTP.
|
||||
* This allows useStream to work seamlessly in an Electron app where the
|
||||
@@ -58,17 +65,24 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
// Track accumulated tool call chunks (for streaming tool calls)
|
||||
private accumulatedToolCalls: Map<string, AccumulatedToolCall> = new Map()
|
||||
|
||||
// Track completed tool calls by name for HITL matching
|
||||
private completedToolCallsByName: Map<string, CompletedToolCall[]> = new Map()
|
||||
|
||||
async stream(payload: StreamPayload): Promise<AsyncGenerator<StreamEvent>> {
|
||||
// Reset state for new stream
|
||||
this.currentMessageId = null
|
||||
this.activeSubagents.clear()
|
||||
this.accumulatedToolCalls.clear()
|
||||
this.completedToolCallsByName.clear()
|
||||
// Extract thread ID from config
|
||||
const threadId = payload.config?.configurable?.thread_id
|
||||
if (!threadId) {
|
||||
return this.createErrorGenerator('MISSING_THREAD_ID', 'Thread ID is required')
|
||||
}
|
||||
|
||||
// Check if this is a resume command (no message needed)
|
||||
const hasResumeCommand = payload.command?.resume !== undefined
|
||||
|
||||
// Extract the message content from input
|
||||
const input = payload.input as
|
||||
| { messages?: Array<{ content: string; type: string }> }
|
||||
@@ -78,7 +92,8 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
const lastHumanMessage = messages.find((m) => m.type === 'human')
|
||||
const messageContent = lastHumanMessage?.content ?? ''
|
||||
|
||||
if (!messageContent) {
|
||||
// Only require message content if not resuming
|
||||
if (!messageContent && !hasResumeCommand) {
|
||||
return this.createErrorGenerator('MISSING_MESSAGE', 'Message content is required')
|
||||
}
|
||||
|
||||
@@ -123,8 +138,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
const sdkEvents = this.convertToSDKEvents(ipcEvent as IPCEvent, threadId)
|
||||
|
||||
for (const sdkEvent of sdkEvents) {
|
||||
console.log('[Transport] Converted event:', sdkEvent)
|
||||
|
||||
if (sdkEvent.event === 'done' || sdkEvent.event === 'error') {
|
||||
isDone = true
|
||||
hasError = sdkEvent.event === 'error'
|
||||
@@ -200,13 +213,10 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
private convertToSDKEvents(event: IPCEvent, threadId: string): StreamEvent[] {
|
||||
const events: StreamEvent[] = []
|
||||
|
||||
console.log('[Transport] convertToSDKEvents:', { type: event.type })
|
||||
|
||||
switch (event.type) {
|
||||
// Raw stream events from LangGraph - parse and convert
|
||||
case 'stream': {
|
||||
const streamEvents = this.processStreamEvent(event)
|
||||
console.log('[Transport] processStreamEvent returned:', streamEvents.length, 'events')
|
||||
events.push(...streamEvents)
|
||||
break
|
||||
}
|
||||
@@ -276,19 +286,50 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
})
|
||||
}
|
||||
|
||||
// Emit interrupt
|
||||
// Emit interrupt - handle both legacy format and new langchain HITL format
|
||||
if (interrupt) {
|
||||
events.push({
|
||||
event: 'custom',
|
||||
data: {
|
||||
type: 'interrupt',
|
||||
request: {
|
||||
id: interrupt.id || crypto.randomUUID(),
|
||||
tool_call: interrupt.tool_call,
|
||||
allowed_decisions: ['approve', 'reject', 'edit']
|
||||
}
|
||||
// Check if this is the new array format from langchain HITL
|
||||
if (Array.isArray(interrupt) && interrupt.length > 0) {
|
||||
const interruptValue = interrupt[0]?.value
|
||||
const actionRequests = interruptValue?.actionRequests
|
||||
const reviewConfigs = interruptValue?.reviewConfigs
|
||||
|
||||
if (actionRequests?.length) {
|
||||
const firstAction = actionRequests[0]
|
||||
const reviewConfig = reviewConfigs?.find(
|
||||
(rc: { actionName: string }) => rc.actionName === firstAction.name
|
||||
)
|
||||
|
||||
events.push({
|
||||
event: 'custom',
|
||||
data: {
|
||||
type: 'interrupt',
|
||||
request: {
|
||||
id: firstAction.id || crypto.randomUUID(),
|
||||
tool_call: {
|
||||
id: firstAction.id,
|
||||
name: firstAction.name,
|
||||
args: firstAction.args || {}
|
||||
},
|
||||
allowed_decisions: reviewConfig?.allowedDecisions || ['approve', 'reject', 'edit']
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
} else if (interrupt.tool_call) {
|
||||
// Legacy format with direct tool_call property
|
||||
events.push({
|
||||
event: 'custom',
|
||||
data: {
|
||||
type: 'interrupt',
|
||||
request: {
|
||||
id: interrupt.id || crypto.randomUUID(),
|
||||
tool_call: interrupt.tool_call,
|
||||
allowed_decisions: ['approve', 'reject', 'edit']
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -324,8 +365,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
const events: StreamEvent[] = []
|
||||
const { mode, data } = event
|
||||
|
||||
console.log('[Transport] processStreamEvent:', { mode, dataType: typeof data })
|
||||
|
||||
if (mode === 'messages') {
|
||||
// Messages mode returns [message, metadata] tuples
|
||||
const [msgChunk, metadata] = data as [SerializedMessageChunk, MessageMetadata]
|
||||
@@ -335,26 +374,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
const classId = Array.isArray(msgChunk?.id) ? msgChunk.id : []
|
||||
const className = classId[classId.length - 1] || ''
|
||||
|
||||
console.log('[Transport] Messages mode chunk:', {
|
||||
className,
|
||||
hasContent: !!kwargs.content,
|
||||
tool_calls_count: kwargs.tool_calls?.length,
|
||||
tool_call_chunks_count: kwargs.tool_call_chunks?.length,
|
||||
name: kwargs.name,
|
||||
tool_call_id: kwargs.tool_call_id
|
||||
})
|
||||
|
||||
// Debug logging
|
||||
if (kwargs.tool_calls?.length || kwargs.tool_call_chunks?.length) {
|
||||
console.log('[Transport] Message with tool calls:', {
|
||||
className,
|
||||
tool_calls: kwargs.tool_calls,
|
||||
tool_call_chunks_count: kwargs.tool_call_chunks?.length,
|
||||
name: kwargs.name,
|
||||
tool_call_id: kwargs.tool_call_id
|
||||
})
|
||||
}
|
||||
|
||||
// Check if this is a ToolMessage (class name contains 'ToolMessage')
|
||||
const isToolMessage = className.includes('ToolMessage') && !!kwargs.tool_call_id
|
||||
|
||||
@@ -367,7 +386,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
this.currentMessageId = msgId
|
||||
|
||||
if (content || kwargs.tool_calls?.length) {
|
||||
console.log('[Transport] Processing AI message:', content?.substring(0, 50) || '(tool calls)')
|
||||
events.push({
|
||||
event: 'messages',
|
||||
data: [
|
||||
@@ -402,6 +420,15 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
if (kwargs.tool_calls?.length) {
|
||||
const subagentEvents = this.processCompletedToolCalls(kwargs.tool_calls)
|
||||
events.push(...subagentEvents)
|
||||
|
||||
// Track tool calls for HITL matching
|
||||
for (const tc of kwargs.tool_calls) {
|
||||
if (tc.id && tc.name) {
|
||||
const existing = this.completedToolCallsByName.get(tc.name) || []
|
||||
existing.push({ id: tc.id, name: tc.name, args: tc.args || {} })
|
||||
this.completedToolCallsByName.set(tc.name, existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,13 +454,11 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
|
||||
// Handle subagent task completion
|
||||
if (kwargs.name === 'task') {
|
||||
console.log('[Transport] ToolMessage detected for:', kwargs.tool_call_id)
|
||||
const completionEvents = this.processToolMessage(kwargs.tool_call_id)
|
||||
events.push(...completionEvents)
|
||||
}
|
||||
}
|
||||
} else if (mode === 'values') {
|
||||
console.log('[Transport] Values mode - processing state')
|
||||
|
||||
// Values mode returns full state with serialized LangChain messages
|
||||
const state = data as {
|
||||
@@ -441,26 +466,24 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
todos?: { id?: string; content?: string; status?: string }[]
|
||||
files?: Record<string, unknown> | Array<{ path: string; is_dir?: boolean; size?: number }>
|
||||
workspacePath?: string
|
||||
__interrupt__?: { id?: string; tool_call?: unknown }
|
||||
// __interrupt__ is an array of interrupt objects from langchain HITL middleware
|
||||
__interrupt__?: Array<{
|
||||
value?: {
|
||||
actionRequests?: Array<{ name: string; id: string; args: Record<string, unknown> }>
|
||||
reviewConfigs?: Array<{ actionName: string; allowedDecisions: string[] }>
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
// Process messages in values mode to extract subagents
|
||||
if (state.messages) {
|
||||
console.log('[Transport] Values mode has', state.messages.length, 'messages')
|
||||
for (const msg of state.messages) {
|
||||
const kwargs = msg.kwargs || {}
|
||||
const classId = Array.isArray(msg.id) ? msg.id : []
|
||||
const className = classId[classId.length - 1] || ''
|
||||
|
||||
console.log('[Transport] Values message:', {
|
||||
className,
|
||||
tool_calls_count: kwargs.tool_calls?.length,
|
||||
tool_call_id: kwargs.tool_call_id
|
||||
})
|
||||
|
||||
// Check for task tool calls in AI messages
|
||||
if (kwargs.tool_calls?.length) {
|
||||
console.log('[Transport] Found message with tool_calls:', kwargs.tool_calls)
|
||||
for (const toolCall of kwargs.tool_calls) {
|
||||
if (
|
||||
toolCall.name === 'task' &&
|
||||
@@ -471,7 +494,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
if (args.subagent_type || args.description) {
|
||||
const subagent = this.createSubagentFromTask(toolCall.id, args)
|
||||
this.activeSubagents.set(toolCall.id, subagent)
|
||||
console.log('[Transport] Detected subagent from values mode:', subagent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,7 +505,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
if (subagent && subagent.status === 'running') {
|
||||
subagent.status = 'completed'
|
||||
subagent.completedAt = new Date()
|
||||
console.log('[Transport] Subagent completed from values mode:', subagent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -524,14 +545,26 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
}
|
||||
})
|
||||
|
||||
events.push({
|
||||
event: 'values',
|
||||
data: {
|
||||
messages: transformedMessages,
|
||||
todos: state.todos,
|
||||
workspacePath: state.workspacePath
|
||||
}
|
||||
})
|
||||
// Only emit values event if we have actual data to update
|
||||
// Don't emit messages: undefined as it would clear the UI
|
||||
const valuesData: Record<string, unknown> = {}
|
||||
if (transformedMessages && transformedMessages.length > 0) {
|
||||
valuesData.messages = transformedMessages
|
||||
}
|
||||
if (state.todos !== undefined) {
|
||||
valuesData.todos = state.todos
|
||||
}
|
||||
if (state.workspacePath) {
|
||||
valuesData.workspacePath = state.workspacePath
|
||||
}
|
||||
|
||||
// Only emit if we have something to update
|
||||
if (Object.keys(valuesData).length > 0) {
|
||||
events.push({
|
||||
event: 'values',
|
||||
data: valuesData
|
||||
})
|
||||
}
|
||||
|
||||
// Emit files/workspace
|
||||
if (state.files) {
|
||||
@@ -554,19 +587,46 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit interrupt
|
||||
if (state.__interrupt__) {
|
||||
events.push({
|
||||
event: 'custom',
|
||||
data: {
|
||||
type: 'interrupt',
|
||||
request: {
|
||||
id: state.__interrupt__.id || crypto.randomUUID(),
|
||||
tool_call: state.__interrupt__.tool_call,
|
||||
allowed_decisions: ['approve', 'reject', 'edit']
|
||||
}
|
||||
// Emit interrupt - langchain HITL returns __interrupt__ as array of { value: HITLRequest }
|
||||
if (state.__interrupt__?.length) {
|
||||
const interruptValue = state.__interrupt__[0]?.value
|
||||
const actionRequests = interruptValue?.actionRequests
|
||||
const reviewConfigs = interruptValue?.reviewConfigs
|
||||
|
||||
// For each action request (tool call) that needs approval
|
||||
if (actionRequests?.length) {
|
||||
// Get the first action request for now (can be extended for batch approvals)
|
||||
const firstAction = actionRequests[0]
|
||||
const reviewConfig = reviewConfigs?.find((rc) => rc.actionName === firstAction.name)
|
||||
|
||||
// The actionRequest doesn't include tool_call.id - look up from tracked tool calls
|
||||
let toolCallId: string | undefined
|
||||
|
||||
// Find the tool call ID from our tracked completed tool calls
|
||||
const trackedToolCalls = this.completedToolCallsByName.get(firstAction.name)
|
||||
|
||||
if (trackedToolCalls && trackedToolCalls.length > 0) {
|
||||
// Get the most recent tool call with this name
|
||||
const lastTracked = trackedToolCalls[trackedToolCalls.length - 1]
|
||||
toolCallId = lastTracked.id
|
||||
}
|
||||
})
|
||||
|
||||
events.push({
|
||||
event: 'custom',
|
||||
data: {
|
||||
type: 'interrupt',
|
||||
request: {
|
||||
id: toolCallId || crypto.randomUUID(),
|
||||
tool_call: {
|
||||
id: toolCallId,
|
||||
name: firstAction.name,
|
||||
args: firstAction.args || {}
|
||||
},
|
||||
allowed_decisions: reviewConfig?.allowedDecisions || ['approve', 'reject', 'edit']
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,7 +689,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
const subagent = this.createSubagentFromTask(chunk.id, args)
|
||||
this.activeSubagents.set(chunk.id, subagent)
|
||||
events.push(this.createSubagentEvent())
|
||||
console.log('[Transport] Detected subagent task:', subagent)
|
||||
}
|
||||
} catch {
|
||||
// Args not complete yet, continue accumulating
|
||||
@@ -658,7 +717,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
const subagent = this.createSubagentFromTask(toolCall.id, args)
|
||||
this.activeSubagents.set(toolCall.id, subagent)
|
||||
events.push(this.createSubagentEvent())
|
||||
console.log('[Transport] Detected subagent task (complete):', subagent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -678,7 +736,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
subagent.status = 'completed'
|
||||
subagent.completedAt = new Date()
|
||||
events.push(this.createSubagentEvent())
|
||||
console.log('[Transport] Subagent completed:', subagent)
|
||||
}
|
||||
|
||||
return events
|
||||
|
||||
@@ -435,13 +435,20 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
const { currentThreadId, pendingApproval } = get()
|
||||
if (!currentThreadId || !pendingApproval) return
|
||||
|
||||
await window.api.agent.interrupt(currentThreadId, {
|
||||
type: decision,
|
||||
tool_call_id: pendingApproval.tool_call.id,
|
||||
edited_args: editedArgs
|
||||
})
|
||||
|
||||
// Clear pending approval immediately so UI updates
|
||||
set({ pendingApproval: null })
|
||||
|
||||
// Send interrupt decision - streaming response handled by useStream hook
|
||||
window.api.agent.interrupt(
|
||||
currentThreadId,
|
||||
{
|
||||
type: decision,
|
||||
tool_call_id: pendingApproval.tool_call.id,
|
||||
edited_args: editedArgs
|
||||
}
|
||||
// Note: We don't pass onEvent here - the useStream hook in ChatContainer
|
||||
// will pick up the stream events on the same channel
|
||||
)
|
||||
},
|
||||
|
||||
// Todo actions
|
||||
|
||||
Reference in New Issue
Block a user