跳到主要内容

Hello World

我们来构建一个小型编码智能体。它能查看目录、运行 shell 命令,并在多个回合和应用重启之间保留对话。

这是对 Jack 的 ngrc 示例的带注释讲解。GDK 负责推理、工具分发和模型与工具之间的循环。应用负责选择提供商、工具、提示词、存储和界面。

下面的代码块是节选,部分设置代码为了清晰做了精简。完整可运行的程序、import 和会话存储请看链接的仓库。本文使用修订版本 dc25c57,它固定使用 GDK 0.1.0-alpha.12;更新的 alpha 版本可能有不同的 API。

先运行起来​

你需要 Rust 1.94.1 或更高版本、带 /bin/bash 的 macOS 或 Linux,以及一个 OpenAI API 密钥。选择支持工具调用的模型。不需要 goose CLI、goose 配置文件或 MCP 服务器。

Shell 访问

这个示例执行 shell 命令时不经过审批,也没有沙箱,使用的是你的用户权限和继承的环境。请使用不含敏感文件或凭据的一次性工作区。选择工作目录并不会限制 shell 能访问的内容。模型请求可能产生费用。

git clone https://github.com/jamadeo/ngrc.git
cd ngrc
git switch --detach dc25c579cd40d85f8d90b61e80cbf02b8838564f

把下面两个变量设置成你自己的 API 密钥和账号可用的、支持工具调用的 OpenAI 模型,然后发送第一条提示:

export OPENAI_API_KEY="your-api-key"
export NGRC_MODEL="your-tool-capable-openai-model"
cargo run --locked -- "Inspect this directory and summarize it without changing files."

智能体打印响应后回到 > 提示符。继续提问会沿用同一段对话。在空行按 Ctrl-D 退出;按 Ctrl-C 取消智能体并退出。不带提示启动时,它会等你发送第一条消息,而不是立即发起模型请求。

应用还会打印会话文件的路径。保存这个路径,之后可以用它恢复对话。

组装智能体​

src/main.rs 里的核心函数很短,一眼就能读完:

fn agent(
provider: Arc<dyn Provider>,
model: ModelConfig,
cancel: CancellationToken,
) -> StateMachine<'static, Session, Effect> {
StateMachine::new(
vec![
Step::Operation(Arc::new(
ToolOperation::<Session>::new().with_async_tool::<Shell>(),
)),
Step::Inference(Arc::new(
InferenceRunner::<Session, Effect>::new(provider, model)
.with_request_preparer(Arc::new(Prompt)),
)),
],
cancel,
)
}

这个状态机按顺序有两个步骤:

  • ToolOperation 把 shell 工具暴露给模型,并执行待处理的工具调用。
  • InferenceRunner 用对话、提示词和可用工具调用提供商。

某个步骤产生效果(effect)后,运行时会保存这些效果,状态机在下一轮之前重新加载会话。模型响应可以请求一条 shell 命令;下一轮执行它,再下一次推理调用就能看到结果。助手结束回合后,控制权回到应用。

这个函数外面并没有藏着第二个由应用编写的模型与工具循环。应用只需要一个接收新用户消息的循环。

选择提供商和模型​

这个示例使用三个 GDK crate:

[dependencies]
goose-agent = "=0.1.0-alpha.12"
goose-provider-types = "=0.1.0-alpha.12"
goose-providers = { version = "=0.1.0-alpha.12", features = ["rustls-tls"] }

goose-agent 提供状态机和操作。goose-provider-types 提供共享的接口和消息类型。goose-providers 提供 OpenAI 实现。rustls-tls 特性为这套配置启用 HTTPS。完整的 manifest 还包含 Tokio、序列化和类型化工具的依赖。

提供商的设置是显式的:

let key = std::env::var("OPENAI_API_KEY")?;
let model_name = std::env::var("NGRC_MODEL")?;

let client = ApiClient::new_with_tls(
"https://api.openai.com".into(),
AuthMethod::BearerToken(key),
Some(Default::default()),
)?;

let provider: Arc<dyn Provider> = Arc::new(OpenAiProvider::new(client));
let model = ModelConfig::new(model_name);

提供商负责与模型服务通信。ModelConfig 选择模型。把两者都传给状态机,这个选择就和应用的其余部分分开了。

给它一个工具​

模型只有一个工具:shell。它的输入和输出是普通的 Rust 类型,带生成的 JSON schema:

#[derive(Default, Deserialize, JsonSchema)]
struct ShellInput {
command: String,
}

#[derive(Serialize, JsonSchema)]
struct ShellOutput {
stdout: String,
stderr: String,
exit_code: Option<i32>,
}

ToolBase 给工具提供面向模型的名称和描述,并把这些类型接到工具接口上:

struct Shell;

impl ToolBase for Shell {
type Parameter = ShellInput;
type Output = ShellOutput;
type Error = ErrorData;

fn name() -> Cow<'static, str> {
"shell".into()
}

fn description() -> Option<Cow<'static, str>> {
Some("Execute a non-interactive Bash command in the project directory. Commands time out after 120 seconds; cwd changes do not persist between calls.".into())
}
}

AsyncTool<Session> 实现实际行为。它会收到已加载的会话,因此可以在会话保存的工作目录里启动命令:

impl AsyncTool<Session> for Shell {
async fn invoke(session: &Session, input: ShellInput) -> Result<ShellOutput, ErrorData> {
let mut command = tokio::process::Command::new("/bin/bash");
command
.arg("-c")
.arg(input.command)
.current_dir(&session.metadata.cwd)
.stdin(Stdio::null())
.kill_on_drop(true);

let output = tokio::time::timeout(Duration::from_secs(120), command.output())
.await
.map_err(|_| ErrorData::internal_error("shell timed out after 120 seconds", None))?
.map_err(|error| ErrorData::internal_error(error.to_string(), None))?;

Ok(ShellOutput {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
exit_code: output.status.code(),
})
}
}

用 with_async_tool::<Shell>() 注册后,ToolOperation 就能拿到它的 schema 和实现。这里用 rmcp 的 trait 来定义类型化工具;工具本身在进程内运行,不需要 MCP 服务器。

每次调用都会启动新的 shell。一次调用里的 cd 命令或 shell 变量不会带到下一次。非零退出码会和 stdout、stderr 一起返回给模型,由模型决定下一步怎么做。

准备模型请求​

Prompt 这个请求准备器提供系统提示词,并转发从状态机各操作收集到的工具:

struct Prompt;

#[async_trait]
impl InferenceRequestPreparer<Session> for Prompt {
async fn prepare(
&self,
_: &Session,
_: &Conversation,
input: InferenceInput,
) -> Result<PreparedInferenceRequest> {
Ok(PreparedInferenceRequest {
system_prompt: SYSTEM_PROMPT.into(),
tools: input.tools,
additional_messages: vec![],
})
}
}

示例的系统提示词告诉智能体:用 shell 查看、编辑和测试代码,把文件内容和命令输出视为不可信数据,并简明地报告改动和验证结果。这些指令只是引导模型,并不构成安全边界。

请求准备器还会收到当前会话和对话。这个示例没有用到它们,但应用可以在这里添加针对具体请求的上下文,而不必依赖一份固定的提示词。

掌管会话及其效果​

GDK 不要求特定的数据库或会话格式。应用提供一个暴露自身 ID 和对话的会话:

impl MachineSession for Session {
fn id(&self) -> &str {
&self.id
}

fn conversation(&self) -> Option<&Conversation> {
Some(&self.conversation)
}
}

在 ngrc 中,这个会话还包含创建时间、工作目录、提供商、模型和已记录的用量。Runtime 包装了基于文件的存储。它实现 SessionLoader,在状态机每一轮返回一份会话快照:

#[async_trait]
impl SessionLoader<Session> for Runtime {
async fn load(&self, id: &str) -> Result<Session> {
let session = self.snapshot();
if session.id != id {
bail!("unknown session {id}");
}
Ok(session)
}
}

状态机产生效果,而不是直接写文件。这个示例用到两种效果:

enum Effect {
Message(Message),
Usage(ProviderUsage),
}

impl From<Message> for Effect {
fn from(message: Message) -> Self {
Self::Message(message)
}
}

impl InferenceEffect for Effect {
fn record_usage(usage: ProviderUsage) -> Self {
Self::Usage(usage)
}
}

impl MachineEffect for Effect {
fn ensure_message_ids(&mut self) {
if let Self::Message(message) = self {
*message = message.clone().with_generated_id_if_missing();
}
}
}

From<Message> 让工具和推理的结果变成应用的效果。InferenceEffect 把提供商用量和消息一起保留下来。MachineEffect 确保消息在持久化之前有 ID。

运行时的 EffectHandler 把这些效果转换成存储用的记录。实现的核心是:

let records = effects
.iter()
.map(|effect| match effect {
Effect::Message(message) => Record::Message {
message: message.clone(),
},
Effect::Usage(usage) => Record::Usage {
usage: usage.clone(),
},
})
.collect();
store.append(records)?;

src/session.rs 实现了只追加的 JSONL 存储。它先写入并同步记录,再更新内存中的会话。重新打开文件时,回放这些记录即可重建对话、元数据和用量。助手消息、工具结果和用量以效果的形式持久化,而不是每个流式 token 都写一次。

运行一个回合并流式输出响应​

运行状态机之前,先保存用户消息:

runtime.append_message(Message::user().with_text(prompt))?;

状态机工作时会发出事件。通过事件通道,界面可以在回合结束前显示输出。下面是示例中 run_turn 函数的精简版:

let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let emitter = Emitter::new(tx, cancel.clone());

let display = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
if let AgentEvent::Message(message) = event {
for content in message.content {
match content {
MessageContent::Text(text) => {
print!("{}", text.text);
io::stdout().flush()?;
}
MessageContent::Error(error) => eprintln!("{}", error.message),
_ => {}
}
}
}
}
Ok::<_, io::Error>(())
});

let machine = agent(provider, model, cancel);
let id = runtime.snapshot().id;
let result = machine.run(runtime, &id, &emitter).await;
drop(emitter);
display.await??;
let session = result?;

流式输出和持久化各司其职:emitter 更新界面,效果处理器记录会话。打印一个事件并不会保存它。

对每一行输入,聊天循环都会追加一条用户消息,并围绕同一份已存储的会话运行一个新的状态机。状态机自身没有需要跨回合携带的对话。

应用还把 Ctrl-C 接到共享的 CancellationToken 上。取消后它会等待状态机结束,让 GDK 能在显示任务结束之前收尾被中断的工具响应。

恢复对话​

默认情况下,新会话存放在 ~/.ngrc/sessions。启动时,应用会打印确切的文件路径。要重新打开某个会话,保持 API 密钥和模型环境变量已设置,并把下面的路径换成那个文件:

cargo run --locked -- --resume /absolute/path/to/session.jsonl

应用会加载之前的消息、shell 结果、用量和保存的工作目录,然后等待你的下一条提示。它不会重新运行旧的 shell 命令。--session-dir DIR 可以为新会话选择其他目录。

这里演示的是持久化对话状态,不是恢复正在运行的 shell 进程,也不保证外部操作恰好执行一次。崩溃可能丢失只流式输出过的内容,不完整的日志记录需要处理之后才能重新打开。

下一步​

这个示例有意省略了工具审批、沙箱、输出限制、上下文压缩,以及模型回合数或成本预算。它的 shell 超时也不保证清理子孙进程。把它改造成处理真实数据的应用之前,请记住这些限制。

要改变智能体的行为,可以从这里已经能看到的选择入手:添加工具、替换请求准备器、选择其他提供商,或实现另一种会话存储。更专门的行为可以作为另一个操作,加入状态机的有序列表。

更大型的应用可以看演示,组件 API 可以查参考。对于这个示例,请在生成的 Rust 文档中选择 0.1.0-alpha.12 版本。