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 { restoreCommand } from './commands/restore.js'
|
||||||
import { e2eCommand } from './commands/e2e.js'
|
import { e2eCommand } from './commands/e2e.js'
|
||||||
import { releaseCommand } from './commands/release.js'
|
import { releaseCommand } from './commands/release.js'
|
||||||
|
import { askCommand } from './commands/ask.js'
|
||||||
|
|
||||||
export async function main(argv: string[]): Promise<void> {
|
export async function main(argv: string[]): Promise<void> {
|
||||||
const args = argv.slice(2)
|
const args = argv.slice(2)
|
||||||
@@ -42,6 +43,13 @@ export async function main(argv: string[]): Promise<void> {
|
|||||||
await runCommand(rest[0])
|
await runCommand(rest[0])
|
||||||
break
|
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':
|
case 'init':
|
||||||
await initCommand(rest[0])
|
await initCommand(rest[0])
|
||||||
break
|
break
|
||||||
@@ -107,6 +115,7 @@ AirCoding V1.0.0 Alpha
|
|||||||
Usage: air <command> [args...]
|
Usage: air <command> [args...]
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
|
ask "<prompt>" Ask the AI to implement a task
|
||||||
run [project] Start a session (spawns TUI)
|
run [project] Start a session (spawns TUI)
|
||||||
init Initialize a new AirCoding project
|
init Initialize a new AirCoding project
|
||||||
doctor [--fix] Run diagnostic checks
|
doctor [--fix] Run diagnostic checks
|
||||||
|
|||||||
@@ -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 {
|
private classify_regex(message: string): string {
|
||||||
const lower = message.toLowerCase()
|
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'
|
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'
|
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'
|
return 'direct_command'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user