feat(ask): air ask "<task>" — interactive AI task execution
New `air ask` command: - Accepts task description from CLI - Auto-initializes project if needed - MainAgent classifies → LLM generates → tools execute → results - Supports Chinese keywords (创建/写/开发/实现 etc.) - Tool call parser supports ```json and ```tool blocks - Executes tools first, then checks DONE (fixes race condition) - Strips GLM </think> reasoning tags from output Usage: air ask "创建一个C++程序打印Hello World" Tested end-to-end with glm-5.1 on new API endpoint. Files correctly created: 13/13 E2E gates pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
266
packages/cli/src/commands/ask.ts
Executable file
266
packages/cli/src/commands/ask.ts
Executable file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* AskCommand - Direct AI task execution
|
||||
* air ask "task description" → MainAgent → LLM → tools → result
|
||||
*
|
||||
* @module packages/cli/src/commands/ask
|
||||
*/
|
||||
|
||||
import { loadConfig } from '../bootstrap/loadConfig.js'
|
||||
import { createRuntime } from '../bootstrap/createRuntime.js'
|
||||
import { initCommand } from './init.js'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { ProviderManager, createAnthropicAdapter, OpenAICompatibleAdapter } from '@aircoding/llm'
|
||||
import { MainAgent } from '@aircoding/runtime'
|
||||
import type { ProviderAdapter } from '@aircoding/contracts'
|
||||
|
||||
export async function askCommand(prompt: string, opts?: { model?: string; maxTurns?: number }): Promise<void> {
|
||||
if (!prompt) {
|
||||
console.log('Usage: air ask "<task description>"')
|
||||
console.log('Example: air ask "create a C++ program that prints hello world"')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const config = loadConfig()
|
||||
const projectRoot = config.project_root || process.cwd()
|
||||
|
||||
// Auto-init if not already initialized
|
||||
if (!existsSync(join(projectRoot, '.air', 'shared', 'project.json'))) {
|
||||
console.log('Project not initialized. Running air init first...\n')
|
||||
await initCommand(projectRoot)
|
||||
}
|
||||
|
||||
const model = opts?.model || process.env.AIRCODING_MODEL || 'glm-5.1'
|
||||
const maxTurns = opts?.maxTurns || 10
|
||||
|
||||
console.log('══════════════════════════════════════════════')
|
||||
console.log(' AirCoding v1.0.0-alpha')
|
||||
console.log(' Project:', projectRoot)
|
||||
console.log(' Model:', model)
|
||||
console.log('══════════════════════════════════════════════\n')
|
||||
|
||||
// Create ProviderManager with available adapter
|
||||
const provider = createProvider(model)
|
||||
|
||||
// Create runtime
|
||||
const runtime = await createRuntime(config)
|
||||
await runtime.start()
|
||||
|
||||
const app = runtime.app
|
||||
|
||||
// Create MainAgent with regex classifier (中文 support added)
|
||||
const agent = new MainAgent({
|
||||
session_id: 'ask-session',
|
||||
project_id: 'ask-project',
|
||||
classify_mode: 'regex',
|
||||
provider_manager: provider as any,
|
||||
classify_model: model
|
||||
})
|
||||
|
||||
console.log('Task:', prompt)
|
||||
console.log('')
|
||||
|
||||
// Classify intent
|
||||
const classification = await agent.handle_user_message(prompt)
|
||||
console.log(`[${classification.action}] ${agent.state}`)
|
||||
|
||||
// Execute based on action
|
||||
if (classification.action === 'delegate') {
|
||||
await execute_task(prompt, provider, app.tool_registry, app.context_assembler, model, projectRoot, maxTurns)
|
||||
} else if (classification.action === 'answer') {
|
||||
console.log('\n' + (classification.response || 'No response'))
|
||||
} else {
|
||||
console.log('Direct mode not yet supported for ask. Use implementation requests.')
|
||||
}
|
||||
|
||||
await runtime.shutdown()
|
||||
}
|
||||
|
||||
async function execute_task(
|
||||
task: string,
|
||||
provider: any,
|
||||
toolRegistry: any,
|
||||
contextAssembler: any,
|
||||
model: string,
|
||||
projectRoot: string,
|
||||
maxTurns: number
|
||||
): Promise<void> {
|
||||
const ctx = {
|
||||
session_id: 'ask-session',
|
||||
project_id: 'ask-project',
|
||||
agent_id: 'ask-agent',
|
||||
permission_template: 'main_direct' as const,
|
||||
cwd: projectRoot
|
||||
}
|
||||
|
||||
const systemPrompt = `You are an AI coding assistant. Help the user by reading files, writing code, and running commands.
|
||||
|
||||
Available tools:
|
||||
- fs.read(path) — Read a file
|
||||
- fs.write(path, content) — Write/create a file
|
||||
- fs.edit(path, old_str, new_str) — Edit a file
|
||||
- fs.list(path, depth?) — List directory contents
|
||||
- fs.stat(path) — Get file info
|
||||
- shell.run(command, timeout?) — Run a shell command
|
||||
- git.status() — Show git status
|
||||
- project.scan(root) — Scan project for source files
|
||||
- cpp.detect() — Detect C++ project
|
||||
- cpp.build(target?) — Build C++ project
|
||||
- cpp.test(filter?) — Run C++ tests
|
||||
|
||||
To use a tool, output EXACTLY:
|
||||
\`\`\`json
|
||||
{"tool": "fs.read", "args": {"path": "file.cpp"}}
|
||||
\`\`\`
|
||||
|
||||
When you are done, output:
|
||||
DONE: <summary of what was done>`
|
||||
|
||||
const messages: any[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: task }
|
||||
]
|
||||
|
||||
let turn = 0
|
||||
const changedFiles: string[] = []
|
||||
|
||||
while (turn < maxTurns) {
|
||||
turn++
|
||||
console.log(`─ Turn ${turn}/${maxTurns} ─`)
|
||||
|
||||
// Call LLM
|
||||
const result = await provider.complete_text(messages, { model, max_tokens: 4096 })
|
||||
let text = result.content || ''
|
||||
|
||||
// Strip GLM reasoning tags if present
|
||||
text = text.replace(/<\/?think>/g, '')
|
||||
|
||||
// Show LLM response summary
|
||||
const preview = text.slice(0, 100).replace(/\n/g, ' ')
|
||||
console.log(` LLM: ${preview}...`)
|
||||
|
||||
// Parse tool calls FIRST (before DONE check — LLM may emit both)
|
||||
const tools = parseToolCalls(text)
|
||||
|
||||
if (tools.length > 0) {
|
||||
console.log(` Tools: ${tools.map(t => t.name).join(', ')}`)
|
||||
|
||||
// Add assistant message
|
||||
messages.push({ role: 'assistant', content: text })
|
||||
|
||||
// Execute each tool call
|
||||
for (const tool of tools) {
|
||||
try {
|
||||
const toolResult = await toolRegistry.call(
|
||||
{ call_id: `ask-${Date.now()}`, name: tool.name, arguments: tool.args },
|
||||
ctx
|
||||
)
|
||||
|
||||
const output = toolResult.status === 'ok'
|
||||
? JSON.stringify(toolResult.output).slice(0, 500)
|
||||
: `Error: ${JSON.stringify(toolResult.error)}`
|
||||
|
||||
console.log(` ✓ ${tool.name}: ${output.slice(0, 100)}`)
|
||||
|
||||
if (tool.name === 'fs.write' || tool.name === 'fs.edit') {
|
||||
changedFiles.push(String(tool.args.path || 'unknown'))
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `Tool ${tool.name} result: ${output}`
|
||||
})
|
||||
} catch (e: any) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `Tool ${tool.name} error: ${e.message}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// After executing tools, check for DONE
|
||||
if (text.includes('DONE:')) {
|
||||
const summary = text.split('DONE:')[1]?.trim() || 'Task completed'
|
||||
console.log(`\n✅ ${summary}`)
|
||||
break
|
||||
}
|
||||
|
||||
// Ask LLM to continue with remaining work
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: 'Tools executed successfully. If the task is complete, respond with DONE: <summary>. Otherwise continue with more tool calls.'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// No tool calls — LLM is just talking
|
||||
// Check for DONE (no tools to execute)
|
||||
if (text.includes('DONE:')) {
|
||||
const summary = text.split('DONE:')[1]?.trim() || 'Task completed'
|
||||
console.log(`\n✅ ${summary}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (turn >= maxTurns) {
|
||||
console.log(`\n⚠️ Reached max ${maxTurns} turns. Task may be incomplete.`)
|
||||
}
|
||||
|
||||
if (changedFiles.length > 0) {
|
||||
console.log(`\nChanged files: ${changedFiles.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseToolCalls(text: string): Array<{ name: string; args: Record<string, unknown> }> {
|
||||
const calls: Array<{ name: string; args: Record<string, unknown> }> = []
|
||||
|
||||
// Pattern 1: ```tool or ```json code blocks
|
||||
const blockRe = /```(?:tool|json)\s*\n?([\s\S]*?)```/g
|
||||
for (const match of text.matchAll(blockRe)) {
|
||||
try {
|
||||
const parsed = JSON.parse(match[1].trim())
|
||||
if (parsed.tool) calls.push({ name: parsed.tool, args: parsed.args || {} })
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// Pattern 2: {"tool": "..."} inline JSON anywhere in text
|
||||
const jsonRe = /\{\s*"tool"\s*:\s*"([^"]+)"\s*,\s*"args"\s*:\s*(\{[^}]+\})\s*\}/g
|
||||
for (const match of text.matchAll(jsonRe)) {
|
||||
try {
|
||||
const name = match[1]
|
||||
const args = JSON.parse(match[2])
|
||||
if (!calls.some(c => c.name === name)) {
|
||||
calls.push({ name, args })
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
return calls
|
||||
}
|
||||
|
||||
function createProvider(model: string): any {
|
||||
const apiKey = process.env.AIRCODING_API_KEY || process.env.OPENAI_API_KEY || ''
|
||||
const apiUrl = process.env.AIRCODING_API_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
|
||||
|
||||
if (!apiKey) {
|
||||
console.log('⚠️ No API key found. Set AIRCODING_API_KEY or OPENAI_API_KEY.')
|
||||
console.log(' Export: export AIRCODING_API_KEY="your-key"')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const adapter = new OpenAICompatibleAdapter({
|
||||
base_url: apiUrl,
|
||||
api_key: apiKey,
|
||||
model
|
||||
})
|
||||
|
||||
// Wrap adapter in a simple provider interface
|
||||
return {
|
||||
adapters: new Map([['openai-compatible', adapter]]),
|
||||
current_adapter: adapter,
|
||||
current_model: model,
|
||||
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}) {
|
||||
return (adapter as any).complete_text(messages, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { sessionCommand } from './commands/session.js'
|
||||
import { restoreCommand } from './commands/restore.js'
|
||||
import { e2eCommand } from './commands/e2e.js'
|
||||
import { releaseCommand } from './commands/release.js'
|
||||
import { askCommand } from './commands/ask.js'
|
||||
|
||||
export async function main(argv: string[]): Promise<void> {
|
||||
const args = argv.slice(2)
|
||||
@@ -42,6 +43,13 @@ export async function main(argv: string[]): Promise<void> {
|
||||
await runCommand(rest[0])
|
||||
break
|
||||
|
||||
case 'ask':
|
||||
await askCommand(rest.join(' '), {
|
||||
model: rest.find(a => a.startsWith('--model='))?.split('=')[1],
|
||||
maxTurns: rest.find(a => a.startsWith('--turns='))?.split('=')[1] ? parseInt(rest.find(a => a.startsWith('--turns='))!.split('=')[1]) : undefined
|
||||
})
|
||||
break
|
||||
|
||||
case 'init':
|
||||
await initCommand(rest[0])
|
||||
break
|
||||
@@ -107,6 +115,7 @@ AirCoding V1.0.0 Alpha
|
||||
Usage: air <command> [args...]
|
||||
|
||||
Commands:
|
||||
ask "<prompt>" Ask the AI to implement a task
|
||||
run [project] Start a session (spawns TUI)
|
||||
init Initialize a new AirCoding project
|
||||
doctor [--fix] Run diagnostic checks
|
||||
|
||||
Reference in New Issue
Block a user