Skip to main content

qcli/
instructions.rs

1//! What the model is told before the conversation: who it is working for, where, and with
2//! which tools, followed by the folder's own `AGENTS.md` when it has one.
3
4use std::path::Path;
5
6use serde_json::{Value, json};
7
8use crate::actions::Action;
9use crate::conversation::Conversation;
10
11/// The name of the file a folder keeps its instructions for coding agents in.
12pub const AGENTS_FILE: &str = "AGENTS.md";
13
14/// The most of `AGENTS.md` that is sent. A file larger than this is most likely not meant as
15/// instructions, and sending it whole with every request would cost the person on every turn.
16const AGENTS_LIMIT: usize = 64 * 1024;
17
18/// How many tokens a reply may use, thinking included.
19const MAX_TOKENS: u32 = 16_384;
20
21/// The system prompt for work in `root`.
22#[must_use]
23pub fn system(root: &Path) -> String {
24    let mut text = format!(
25        "You are qcli, a coding agent in a terminal. You work in the folder {}. \
26         Use the tools to look at the files before you answer questions about them. \
27         Paths are relative to that folder, and nothing outside it can be reached. \
28         Every edit and every command is shown to the person first, and they may decline it; \
29         when they do, do not try the same thing another way, ask what they want instead. \
30         Keep answers short and plain.",
31        root.display()
32    );
33    if let Some(agents) = agents(root) {
34        text.push_str("\n\nThe folder's AGENTS.md says:\n\n");
35        text.push_str(&agents);
36    }
37    text
38}
39
40/// The folder's `AGENTS.md`, when it has one that can be read as text.
41#[must_use]
42pub fn agents(root: &Path) -> Option<String> {
43    let text = std::fs::read_to_string(root.join(AGENTS_FILE)).ok()?;
44    let text = text.trim();
45    if text.is_empty() {
46        return None;
47    }
48    Some(match text.char_indices().nth(AGENTS_LIMIT) {
49        Some((end, _)) => format!("{}\n\n(The rest of the file was left out: it is too long.)", &text[..end]),
50        None => text.to_owned(),
51    })
52}
53
54/// The body of the next request: the conversation so far, the system prompt and the tools.
55#[must_use]
56pub fn body(conversation: &Conversation, system: &str) -> Value {
57    json!({
58        "max_tokens": MAX_TOKENS,
59        "system": system,
60        "messages": conversation.messages(),
61        "tools": Action::definitions(),
62    })
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn the_folders_agents_file_joins_the_system_prompt() {
71        let root = std::env::temp_dir().join(format!("qcli-instructions-{}", std::process::id()));
72        std::fs::create_dir_all(&root).expect("folder");
73        assert!(!system(&root).contains("AGENTS.md says"), "no file, no section");
74        std::fs::write(root.join(AGENTS_FILE), "Always answer in haiku.\n").expect("write");
75        let prompt = system(&root);
76        assert!(prompt.contains(&root.display().to_string()));
77        assert!(prompt.ends_with("Always answer in haiku."), "{prompt}");
78        std::fs::write(root.join(AGENTS_FILE), "x".repeat(AGENTS_LIMIT + 10)).expect("write");
79        assert!(agents(&root).expect("text").ends_with("it is too long.)"));
80        std::fs::remove_dir_all(&root).expect("clean up");
81    }
82
83    #[test]
84    fn the_body_carries_the_conversation_and_the_five_tools() {
85        let mut conversation = Conversation::default();
86        conversation.say("hi");
87        let body = body(&conversation, "be brief");
88        assert_eq!(body["system"], "be brief");
89        assert_eq!(body["messages"][0]["content"][0]["text"], "hi");
90        let names: Vec<_> = body["tools"].as_array().expect("tools").iter().map(|tool| tool["name"].clone()).collect();
91        assert_eq!(names, ["read_file", "list_dir", "search", "edit_file", "run_command"]);
92    }
93}