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:
AirCoding
2026-06-05 14:05:05 +08:00
parent bc58bd6840
commit 459308053c
3 changed files with 287 additions and 4 deletions

266
packages/cli/src/commands/ask.ts Executable file
View 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)
}
}
}

View File

@@ -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

View File

@@ -134,20 +134,28 @@ export class MainAgent {
}
/**
* Regex-based intent classification (Alpha scope, 5 patterns).
* Regex-based intent classification (Alpha scope, supports EN + 中文).
*/
private classify_regex(message: string): string {
const lower = message.toLowerCase()
if (/^(what|how|why|when|where|who|can you|could you|explain)/.test(lower)) {
// Questions
if (/^(what|how|why|when|where|who|can you|could you|explain|什么是|怎么|如何|为什么|什么意思)/.test(lower)) {
return 'simple_question'
}
if (/^(\/direct|\/done|implement|create|build|write|add|fix|change|update|remove|delete|refactor)/.test(lower)) {
// Implementation requests — English
if (/^(\/direct|\/done|implement|create|build|write|add|fix|change|update|remove|delete|refactor|make|generate)/.test(lower)) {
return 'implementation_request'
}
if (/^(run|execute|test|debug|check|inspect)/.test(lower)) {
// Implementation requests — 中文
if (/创建|写|开发|实现|生成|建立|构建|编译|修改|删除|添加|增加|修复|重构|制作/.test(message)) {
return 'implementation_request'
}
// Direct commands
if (/^(run|execute|test|debug|check|inspect|运行|执行|测试|调试|检查)/.test(lower)) {
return 'direct_command'
}