a3s-code-core 6.9.0

A3S Code Core - Embeddable AI agent library with tool execution
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
//! Bounded personal and project instruction discovery for workspace sessions.
//!
//! A session gets one immutable instruction chain at construction time. The
//! chain starts with the personal `~/.a3s` document, then walks from the
//! nearest Git root to the selected workspace. Later, more local documents
//! therefore have the final word.

use crate::config::CodeConfig;
use crate::context::{ContextItem, ContextType};
use std::io::Read;
use std::path::{Path, PathBuf};

const DEFAULT_PROJECT_DOC_MAX_BYTES: usize = 32 * 1024;
const MAX_PROJECT_DOC_MAX_BYTES: usize = 1024 * 1024;
const MAX_PROJECT_INSTRUCTION_DEPTH: usize = 256;
const PROJECT_DOC_SEPARATOR: &str = "\n\n--- instruction-doc ---\n\n";
const PROJECT_DOC_SOURCES_METADATA: &str = "a3s.context.project_instruction_sources";

pub(super) fn load_context_item(code_config: &CodeConfig, workspace: &Path) -> Option<ContextItem> {
    let loaded = load_project_instructions(code_config, workspace)?;
    let source = if loaded.sources.len() == 1 {
        format!("file://{}", loaded.sources[0].display())
    } else {
        "a3s://workspace-instructions".to_string()
    };
    let source_uris = loaded
        .sources
        .iter()
        .map(|path| format!("file://{}", path.display()))
        .collect::<Vec<_>>();
    let content = format!(
        "# Instructions (personal + project AGENTS.md chain)\n\n{}",
        loaded.content
    );
    let token_count = content.split_whitespace().count().max(1);

    tracing::info!(
        files = loaded.sources.len(),
        bytes = loaded.loaded_bytes,
        workspace = %workspace.display(),
        "Auto-loaded hierarchical personal and project instructions"
    );
    Some(
        ContextItem::new("agents_md", ContextType::Resource, content)
            .with_source(source)
            .with_metadata(PROJECT_DOC_SOURCES_METADATA, serde_json::json!(source_uris))
            .with_provenance("workspace_instructions")
            .with_priority(1.0)
            .with_trust(0.95)
            .with_freshness(1.0)
            .with_relevance(1.0)
            .with_token_count(token_count)
            .with_required(),
    )
}

#[derive(Debug)]
struct LoadedProjectInstructions {
    content: String,
    sources: Vec<PathBuf>,
    loaded_bytes: usize,
}

fn load_project_instructions(
    code_config: &CodeConfig,
    workspace: &Path,
) -> Option<LoadedProjectInstructions> {
    let max_bytes = code_config
        .project_doc_max_bytes
        .unwrap_or(DEFAULT_PROJECT_DOC_MAX_BYTES)
        .min(MAX_PROJECT_DOC_MAX_BYTES);
    if max_bytes == 0 {
        return None;
    }
    if code_config
        .project_doc_max_bytes
        .is_some_and(|value| value > max_bytes)
    {
        tracing::warn!(
            configured_bytes = code_config.project_doc_max_bytes.unwrap_or(max_bytes),
            effective_bytes = max_bytes,
            "project_doc_max_bytes exceeded the harness safety ceiling and was clamped"
        );
    }

    let search_dirs = project_instruction_directories(workspace);
    let project_root = search_dirs.first().cloned()?;
    let candidate_names = project_instruction_candidate_names(code_config);
    let mut remaining = max_bytes;
    let mut contents = Vec::new();
    let mut sources = Vec::new();
    let mut loaded_bytes = 0usize;

    if let Some(user_directory) = user_instruction_directory(code_config) {
        load_first_instruction_in_directory(
            &user_directory,
            &user_directory,
            &["AGENTS.override.md".to_string(), "AGENTS.md".to_string()],
            &mut remaining,
            &mut contents,
            &mut sources,
            &mut loaded_bytes,
            "personal",
        );
    }

    for directory in search_dirs {
        if remaining == 0 {
            break;
        }
        load_first_instruction_in_directory(
            &directory,
            &project_root,
            &candidate_names,
            &mut remaining,
            &mut contents,
            &mut sources,
            &mut loaded_bytes,
            "project",
        );
    }

    (!contents.is_empty()).then(|| LoadedProjectInstructions {
        content: contents.join(PROJECT_DOC_SEPARATOR),
        sources,
        loaded_bytes,
    })
}

fn user_instruction_directory(code_config: &CodeConfig) -> Option<PathBuf> {
    let configured = code_config
        .user_instructions_dir
        .clone()
        .or_else(|| dirs::home_dir().map(|home| home.join(".a3s")))?;
    let metadata = std::fs::symlink_metadata(&configured).ok()?;
    if metadata.file_type().is_symlink() || !metadata.is_dir() {
        tracing::warn!(
            path = %configured.display(),
            "Ignoring personal instruction directory that is not a regular non-symlink directory"
        );
        return None;
    }
    Some(super::safe_canonicalize(&configured))
}

#[allow(clippy::too_many_arguments)]
fn load_first_instruction_in_directory(
    directory: &Path,
    allowed_root: &Path,
    candidate_names: &[String],
    remaining: &mut usize,
    contents: &mut Vec<String>,
    sources: &mut Vec<PathBuf>,
    loaded_bytes: &mut usize,
    scope: &str,
) {
    if *remaining == 0 {
        return;
    }
    for name in candidate_names {
        let Some(path) = select_project_instruction_file(directory, allowed_root, name) else {
            continue;
        };
        match read_project_instruction_file(&path, *remaining) {
            Ok(Some((content, bytes_read, truncated))) => {
                if truncated {
                    tracing::warn!(
                        path = %path.display(),
                        remaining_bytes = *remaining,
                        "Instruction file exceeded the remaining budget and was truncated"
                    );
                }
                *remaining = remaining.saturating_sub(bytes_read);
                *loaded_bytes = loaded_bytes.saturating_add(bytes_read);
                contents.push(content);
                sources.push(path);
                break;
            }
            Ok(None) => {
                tracing::debug!(path = %path.display(), scope, "Instruction file is empty - trying the next candidate");
            }
            Err(error) => {
                tracing::warn!(
                    path = %path.display(),
                    error = %error,
                    scope,
                    "Failed to read instruction file - trying the next candidate"
                );
            }
        }
    }
}

fn project_instruction_directories(workspace: &Path) -> Vec<PathBuf> {
    let mut ancestors = Vec::new();
    let mut found_root = false;
    for directory in workspace.ancestors().take(MAX_PROJECT_INSTRUCTION_DEPTH) {
        ancestors.push(directory.to_path_buf());
        if is_project_root(directory) {
            found_root = true;
            break;
        }
    }
    if !found_root {
        return vec![workspace.to_path_buf()];
    }
    ancestors.reverse();
    ancestors
}

fn is_project_root(directory: &Path) -> bool {
    std::fs::symlink_metadata(directory.join(".git"))
        .map(|metadata| {
            !metadata.file_type().is_symlink() && (metadata.is_file() || metadata.is_dir())
        })
        .unwrap_or(false)
}

fn project_instruction_candidate_names(code_config: &CodeConfig) -> Vec<String> {
    let mut names = vec!["AGENTS.override.md".to_string(), "AGENTS.md".to_string()];
    for configured in &code_config.project_doc_fallback_filenames {
        let candidate = configured.trim();
        if !is_safe_project_instruction_filename(candidate) {
            tracing::warn!(
                filename = configured,
                "Ignoring unsafe project instruction fallback filename"
            );
            continue;
        }
        if !names.iter().any(|existing| existing == candidate) {
            names.push(candidate.to_string());
        }
    }
    names
}

fn is_safe_project_instruction_filename(candidate: &str) -> bool {
    !candidate.is_empty()
        && candidate != "."
        && candidate != ".."
        && candidate.len() <= 255
        && !candidate
            .chars()
            .any(|character| matches!(character, '/' | '\\' | ':' | '\0'))
}

fn select_project_instruction_file(
    directory: &Path,
    project_root: &Path,
    candidate_name: &str,
) -> Option<PathBuf> {
    let candidate = directory.join(candidate_name);
    let Ok(metadata) = std::fs::symlink_metadata(&candidate) else {
        return None;
    };
    if metadata.file_type().is_symlink() || !metadata.is_file() {
        tracing::warn!(
            path = %candidate.display(),
            "Ignoring instruction candidate that is not a regular non-symlink file"
        );
        return None;
    }
    let resolved = super::safe_canonicalize(&candidate);
    let resolved_root = super::safe_canonicalize(project_root);
    if !resolved.starts_with(&resolved_root) {
        tracing::warn!(path = %resolved.display(), "Ignoring instruction candidate outside its allowed root");
        return None;
    }
    Some(resolved)
}

fn read_project_instruction_file(
    path: &Path,
    remaining: usize,
) -> std::io::Result<Option<(String, usize, bool)>> {
    let mut bytes = Vec::with_capacity(remaining.min(64 * 1024).saturating_add(1));
    std::fs::File::open(path)?
        .take(remaining.saturating_add(1) as u64)
        .read_to_end(&mut bytes)?;
    let truncated = bytes.len() > remaining;
    if truncated {
        bytes.truncate(remaining);
    }
    let content = match std::str::from_utf8(&bytes) {
        Ok(content) => content.to_string(),
        Err(error) if truncated && error.error_len().is_none() => {
            bytes.truncate(error.valid_up_to());
            String::from_utf8(bytes).expect("valid UTF-8 prefix after boundary truncation")
        }
        Err(error) => {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                format!("project instruction file is not valid UTF-8: {error}"),
            ));
        }
    };
    if content.trim().is_empty() {
        return Ok(None);
    }
    let bytes_read = content.len();
    Ok(Some((content, bytes_read, truncated)))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn repo() -> tempfile::TempDir {
        let directory = tempfile::tempdir().unwrap();
        std::fs::create_dir(directory.path().join(".git")).unwrap();
        directory
    }

    #[test]
    fn loads_root_to_workspace_and_prefers_override_per_directory() {
        let directory = repo();
        let nested = directory.path().join("crates").join("code");
        std::fs::create_dir_all(&nested).unwrap();
        std::fs::write(directory.path().join("AGENTS.md"), "root guidance").unwrap();
        std::fs::write(
            directory.path().join("crates/AGENTS.md"),
            "shadowed guidance",
        )
        .unwrap();
        std::fs::write(
            directory.path().join("crates/AGENTS.override.md"),
            "crate override",
        )
        .unwrap();
        std::fs::write(nested.join("AGENTS.md"), "workspace guidance").unwrap();

        let loaded = load_project_instructions(&CodeConfig::default(), &nested).unwrap();
        assert_eq!(loaded.sources.len(), 3);
        assert_eq!(
            loaded.content,
            "root guidance\n\n--- instruction-doc ---\n\ncrate override\n\n--- instruction-doc ---\n\nworkspace guidance"
        );
        assert!(!loaded.content.contains("shadowed guidance"));
    }

    #[test]
    fn uses_safe_fallbacks_and_does_not_walk_above_a_missing_project_root() {
        let directory = tempfile::tempdir().unwrap();
        let workspace = directory.path().join("workspace");
        std::fs::create_dir(&workspace).unwrap();
        std::fs::write(directory.path().join("AGENTS.md"), "parent guidance").unwrap();
        std::fs::write(workspace.join("TEAM_GUIDE.md"), "workspace fallback").unwrap();
        let config = CodeConfig {
            project_doc_fallback_filenames: vec![
                "../outside.md".to_string(),
                "TEAM_GUIDE.md".to_string(),
            ],
            ..Default::default()
        };

        let loaded = load_project_instructions(&config, &workspace).unwrap();
        assert_eq!(loaded.content, "workspace fallback");
        assert_eq!(loaded.sources.len(), 1);
    }

    #[test]
    fn enforces_combined_byte_budget_in_root_to_workspace_order() {
        let directory = repo();
        let nested = directory.path().join("nested");
        std::fs::create_dir(&nested).unwrap();
        std::fs::write(directory.path().join("AGENTS.md"), "root").unwrap();
        std::fs::write(nested.join("AGENTS.md"), "deeper").unwrap();
        let config = CodeConfig {
            project_doc_max_bytes: Some(7),
            ..Default::default()
        };

        let loaded = load_project_instructions(&config, &nested).unwrap();
        assert_eq!(loaded.loaded_bytes, 7);
        assert_eq!(loaded.content, "root\n\n--- instruction-doc ---\n\ndee");
    }

    #[test]
    fn prepends_personal_override_and_shares_the_combined_budget() {
        let directory = repo();
        let personal = tempfile::tempdir().unwrap();
        std::fs::write(personal.path().join("AGENTS.override.md"), "personal").unwrap();
        std::fs::write(personal.path().join("AGENTS.md"), "shadowed personal").unwrap();
        std::fs::write(directory.path().join("AGENTS.md"), "project").unwrap();
        let config = CodeConfig {
            user_instructions_dir: Some(personal.path().to_path_buf()),
            project_doc_max_bytes: Some(11),
            ..Default::default()
        };

        let loaded = load_project_instructions(&config, directory.path()).unwrap();
        assert_eq!(loaded.sources.len(), 2);
        assert_eq!(loaded.content, "personal\n\n--- instruction-doc ---\n\npro");
        assert!(!loaded.content.contains("shadowed personal"));
        assert_eq!(loaded.loaded_bytes, 11);
    }

    #[test]
    fn empty_personal_override_falls_back_to_personal_agents_file() {
        let directory = repo();
        let personal = tempfile::tempdir().unwrap();
        std::fs::write(personal.path().join("AGENTS.override.md"), " \n").unwrap();
        std::fs::write(personal.path().join("AGENTS.md"), "personal fallback").unwrap();
        let config = CodeConfig {
            user_instructions_dir: Some(personal.path().to_path_buf()),
            ..Default::default()
        };

        let loaded = load_project_instructions(&config, directory.path()).unwrap();
        assert_eq!(loaded.content, "personal fallback");
    }

    #[test]
    fn rejects_invalid_utf8_and_unsafe_symlink_candidates() {
        let directory = repo();
        let invalid = directory.path().join("INVALID.md");
        std::fs::write(&invalid, [0xff, 0xfe]).unwrap();
        let config = CodeConfig {
            project_doc_fallback_filenames: vec!["INVALID.md".to_string()],
            ..Default::default()
        };
        assert!(load_project_instructions(&config, directory.path()).is_none());

        std::fs::write(directory.path().join("AGENTS.md"), "safe guidance").unwrap();
        let outside = tempfile::tempdir().unwrap();
        let outside_file = outside.path().join("outside.md");
        std::fs::write(&outside_file, "outside guidance").unwrap();
        let override_path = directory.path().join("AGENTS.override.md");
        #[cfg(unix)]
        let linked = std::os::unix::fs::symlink(&outside_file, &override_path).is_ok();
        #[cfg(windows)]
        let linked = std::os::windows::fs::symlink_file(&outside_file, &override_path).is_ok();
        if linked {
            let loaded =
                load_project_instructions(&CodeConfig::default(), directory.path()).unwrap();
            assert_eq!(loaded.content, "safe guidance");
            assert!(!loaded.content.contains("outside guidance"));
        }
    }
}