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 most a request may be, in bytes: the conversation, the system prompt and the tools
22/// together. Four bytes to a token is a good rough count of plain text, so this is about sixty
23/// five thousand tokens of input, which fits a hundred and twenty eight thousand token context
24/// with this crate's reply still allowed. A real conversation is far below it, so a normal day
25/// is sent byte for byte as it always was; a morning of work in a real folder is not, and
26/// without a limit that size is paid for again on every single turn, and a provider that refuses
27/// a request too long says so only in an error in the corner.
28pub const REQUEST_CAP: usize = 256 * 1024;
29
30/// The system prompt for work in `root`.
31#[must_use]
32pub fn system(root: &Path) -> String {
33    let mut text = format!(
34        "You are qcli, a coding agent in a terminal. You work in the folder {}. \
35         Use the tools to look at the files before you answer questions about them: \
36         list_dir to see the shape of a folder and how big its files are, read_file the file you \
37         need, search to find where something is. \
38         In a file longer than 2000 lines read the part you need with start_line and end_line \
39         rather than asking for the whole of it, and read on with the lines the reply says are \
40         not shown. \
41         When a search finds nothing, try again with ignore_case or with a glob such as *.rs \
42         rather than sending the same search again. \
43         Paths are relative to that folder, and nothing outside it can be reached. \
44         Every edit and every command is shown to the person first, and they may decline it; \
45         when they do, do not try the same thing another way, ask what they want instead. \
46         Keep answers short and plain.",
47        root.display()
48    );
49    if let Some(agents) = agents(root) {
50        text.push_str("\n\nThe folder's AGENTS.md says:\n\n");
51        text.push_str(&agents);
52    }
53    text
54}
55
56/// The folder's `AGENTS.md`, when it has one that can be read as text.
57#[must_use]
58pub fn agents(root: &Path) -> Option<String> {
59    let text = std::fs::read_to_string(root.join(AGENTS_FILE)).ok()?;
60    let text = text.trim();
61    if text.is_empty() {
62        return None;
63    }
64    Some(match text.char_indices().nth(AGENTS_LIMIT) {
65        Some((end, _)) => format!("{}\n\n(The rest of the file was left out: it is too long.)", &text[..end]),
66        None => text.to_owned(),
67    })
68}
69
70/// The body of the next request: the conversation as far back as [`REQUEST_CAP`] allows, the
71/// system prompt and the tools.
72#[must_use]
73pub fn body(conversation: &Conversation, system: &str) -> Value {
74    body_within(conversation, system, REQUEST_CAP)
75}
76
77/// The body of a request that is at most `cap` bytes: the conversation trimmed to what is left of
78/// `cap` once the rest of the body is counted in, that rest, and nothing else. `body` is this
79/// with [`REQUEST_CAP`]; a cap of [`usize::MAX`] sends the conversation as it is kept, which is
80/// what the trimmed request is measured against.
81#[must_use]
82pub fn body_within(conversation: &Conversation, system: &str, cap: usize) -> Value {
83    let tools = Action::definitions();
84    // What every request of this conversation carries whatever is said in it. The empty array of
85    // messages is two bytes, and the messages take its place inside it.
86    let rest = serde_json::to_string(&json!({
87        "max_tokens": MAX_TOKENS,
88        "system": system,
89        "messages": [],
90        "tools": tools,
91    }))
92    .map_or(0, |body| body.len())
93    .saturating_sub(2);
94    json!({
95        "max_tokens": MAX_TOKENS,
96        "system": system,
97        "messages": conversation.view(cap.saturating_sub(rest)),
98        "tools": tools,
99    })
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    /// A fresh folder named after the test that uses it, so two tests running at once never share
107    /// one.
108    fn folder(test: &str) -> std::path::PathBuf {
109        let root = std::env::temp_dir().join(format!("qcli-instructions-{test}-{}", std::process::id()));
110        let _ = std::fs::remove_dir_all(&root);
111        std::fs::create_dir_all(&root).expect("folder");
112        root
113    }
114
115    #[test]
116    fn the_folders_agents_file_joins_the_system_prompt() {
117        let root = folder("agents");
118        assert!(!system(&root).contains("AGENTS.md says"), "no file, no section");
119        std::fs::write(root.join(AGENTS_FILE), "Always answer in haiku.\n").expect("write");
120        let prompt = system(&root);
121        assert!(prompt.contains(&root.display().to_string()));
122        assert!(prompt.ends_with("Always answer in haiku."), "{prompt}");
123        std::fs::write(root.join(AGENTS_FILE), "x".repeat(AGENTS_LIMIT + 10)).expect("write");
124        assert!(agents(&root).expect("text").ends_with("it is too long.)"));
125        std::fs::remove_dir_all(&root).expect("clean up");
126    }
127
128    #[test]
129    fn the_body_carries_the_conversation_and_the_five_tools() {
130        let mut conversation = Conversation::default();
131        conversation.say("hi");
132        let body = body(&conversation, "be brief");
133        assert_eq!(body["system"], "be brief");
134        assert_eq!(body["messages"][0]["content"][0]["text"], "hi");
135        let names: Vec<_> = body["tools"].as_array().expect("tools").iter().map(|tool| tool["name"].clone()).collect();
136        assert_eq!(names, ["read_file", "list_dir", "search", "edit_file", "run_command"]);
137    }
138
139    #[test]
140    fn the_system_prompt_says_how_to_use_the_new_fields() {
141        let root = folder("fields");
142        let prompt = system(&root);
143        for said in [
144            "list_dir",
145            "start_line",
146            "end_line",
147            "ignore_case",
148            "glob",
149            "*.rs",
150            "do not try the same thing another way",
151        ] {
152            assert!(prompt.contains(said), "`{said}` is not in the prompt:\n{prompt}");
153        }
154        std::fs::remove_dir_all(&root).expect("clean up");
155    }
156}