Repository navigation
Expand file tree
/
Copy pathchat.rs
More file actions
5439 lines (5172 loc) · 209 KB
/
Copy pathchat.rs
File metadata and controls
5439 lines (5172 loc) · 209 KB
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
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Bare `aster`: a conversational turn with an agentic read/list/search/edit tool loop.
use std::collections::{HashMap, HashSet};
use std::io::{IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use std::{env, fs, io};
use anyhow::{Context, Result, bail};
use aster_ai::{
AiClient, Annotation, ChatMessage, DegenerateOutput, ReasoningDetail, UsageSnapshot,
};
use aster_persist::{
EventUsage, EvictionEvent, MessageEvent, ReasoningRecord, Store, SummaryEvent, TranscriptEvent,
};
use aster_policy::{Action, Decision, Grants, Policy};
use clap::Args;
use serde::Deserialize;
use serde_json::{Value, json};
use tokio::sync::{mpsc, oneshot};
use tracing::Instrument;
use crate::config::provider::MissingCredentials;
use crate::edits::{self, EditBlock};
use crate::mcp::ToolOutput;
use crate::persist::Recorder;
use crate::util::usage_json;
#[derive(Default, Clone)]
pub(crate) struct SessionCtx {
pub recorder: Option<Recorder>,
pub store: Option<Store>,
pub skills: Arc<aster_skills::SkillSet>,
pub instructions: Arc<crate::instructions::Instructions>,
pub probe: Arc<bash_tools::ToolProbe>,
pub plan: std::sync::Arc<std::sync::Mutex<PlanState>>,
pub mcp: Option<crate::mcp::McpRuntime>,
pub limits: Limits,
pub environment: Option<String>,
/// Shared so an ACP session can flip it when the editor changes the mode
/// mid-session; the TUI rebuilds the ctx each turn and does not need that.
pub yolo: Arc<AtomicBool>,
pub credentials: Arc<aster_policy::CommandGrants>,
pub reads: Arc<Mutex<HashMap<String, Option<std::time::SystemTime>>>>,
pub previews: Arc<Mutex<HashSet<String>>>,
pub lookups: Arc<Mutex<HashSet<String>>>,
pub injected: Arc<std::sync::Mutex<Vec<String>>>,
pub agents: Arc<aster_agents::AgentRegistry>,
pub sub_agent: Option<Arc<SubAgentOverrides>>,
pub swarm: SwarmLimits,
}
/// How long a turn may work before it has to answer, how long one command may
/// run, and which language replies are written in. Defaults suit real builds;
/// `aster.yaml` and the env can change them.
#[derive(Debug, Clone)]
pub(crate) struct Limits {
pub max_tool_rounds: usize,
pub command_timeout_secs: usize,
pub compact_budget_chars: usize,
/// `None` follows the language the user writes in.
pub language: Option<String>,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_tool_rounds: DEFAULT_MAX_TOOL_ROUNDS,
command_timeout_secs: DEFAULT_COMMAND_TIMEOUT_SECS,
compact_budget_chars: COMPACT_BUDGET_CHARS,
language: None,
}
}
}
impl Limits {
/// aster.yaml first, then the environment, which wins so one run can differ.
pub(crate) fn resolve(agent: &crate::settings::Agent) -> Self {
let env_usize = |key: &str| std::env::var(key).ok().and_then(|v| v.parse().ok());
Self {
max_tool_rounds: env_usize("ASTER_MAX_TOOL_ROUNDS")
.or(agent.max_tool_rounds)
.unwrap_or(DEFAULT_MAX_TOOL_ROUNDS)
.max(1),
command_timeout_secs: env_usize("ASTER_COMMAND_TIMEOUT")
.or(agent.command_timeout_secs.map(|v| v as usize))
.unwrap_or(DEFAULT_COMMAND_TIMEOUT_SECS)
.max(1),
compact_budget_chars: env_usize("ASTER_COMPACT_BUDGET")
.or(agent.compact_budget_chars)
.unwrap_or(COMPACT_BUDGET_CHARS)
.max(COMPACT_KEEP_TAIL * 1_000),
language: std::env::var("ASTER_LANGUAGE")
.ok()
.or_else(|| agent.language.clone())
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty()),
}
}
}
/// Where replies are written in. Sits at the end of the prompt, after the code
/// and tool text that pulls models off the user's language mid-turn.
pub(crate) fn language_note(language: Option<&str>) -> String {
let target = match language {
Some(lang) => format!("Reply in {lang}, whatever language the user writes in."),
None => "Reply in the language the user writes in.".to_string(),
};
format!(
"## Language\n{target} Keep the whole reply in that one language, \
including any reasoning before it. Never drift into another language \
mid-turn, even when the context is mostly code or tool output."
)
}
/// Caps on the sub-agent fan-out. aster.yaml first, then the environment.
#[derive(Debug, Clone)]
pub(crate) struct SwarmLimits {
pub max_concurrent: usize,
pub max_per_turn: usize,
pub agent_timeout_secs: u64,
pub collector_model: Option<String>,
}
impl SwarmLimits {
pub(crate) fn resolve(agents: &crate::settings::Agents) -> Self {
let env_usize = |key: &str| std::env::var(key).ok().and_then(|v| v.parse().ok());
let env_u64 = |key: &str| std::env::var(key).ok().and_then(|v| v.parse().ok());
Self {
max_concurrent: env_usize("ASTER_AGENT_MAX_CONCURRENT")
.or(agents.max_concurrent)
.unwrap_or(8)
.max(1),
max_per_turn: env_usize("ASTER_AGENT_MAX_PER_TURN")
.or(agents.max_per_turn)
.unwrap_or(24)
.max(1),
agent_timeout_secs: env_u64("ASTER_AGENT_TIMEOUT")
.or(agents.agent_timeout_secs)
.unwrap_or(DEFAULT_AGENT_TIMEOUT_SECS)
.max(1),
collector_model: std::env::var("ASTER_COLLECTOR_MODEL")
.ok()
.or_else(|| agents.collector_model.clone()),
}
}
}
impl Default for SwarmLimits {
fn default() -> Self {
Self {
max_concurrent: 8,
max_per_turn: 24,
agent_timeout_secs: DEFAULT_AGENT_TIMEOUT_SECS,
collector_model: None,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct SubAgentOverrides {
pub prompt_body: String,
pub tool_allowlist: std::collections::HashSet<String>,
}
/// The plan document drafted with `write_plan` and presented by
/// `exit_plan_mode`, plus the progress steps tracked with `update_plan`.
#[derive(Debug, Default, Clone)]
pub(crate) struct PlanState {
pub document: String,
pub steps: Vec<PlanStep>,
pub approved: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct PlanStep {
pub label: String,
#[serde(rename = "status")]
pub status: PlanStepStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum PlanStepStatus {
Pending,
#[serde(rename = "in_progress")]
InProgress,
Done,
Skipped,
Blocked,
}
fn plan_snapshot(ctx: &SessionCtx) -> Option<Vec<(String, PlanStepStatus)>> {
let plan = ctx.plan.lock().ok()?;
(!plan.steps.is_empty()).then(|| {
plan.steps
.iter()
.map(|s| (s.label.clone(), s.status))
.collect()
})
}
fn plan_unfinished(snapshot: &Option<Vec<(String, PlanStepStatus)>>) -> bool {
snapshot.as_ref().is_some_and(|steps| {
steps.iter().any(|(_, status)| {
matches!(status, PlanStepStatus::Pending | PlanStepStatus::InProgress)
})
})
}
impl SessionCtx {
pub(crate) fn record(&self, event: MessageEvent) {
let Some(recorder) = &self.recorder else {
return;
};
match recorder.lock() {
Ok(mut writer) => {
if let Err(e) = writer.append_message(event) {
tracing::warn!("failed to record transcript event: {e:#}");
}
}
Err(e) => tracing::warn!("transcript writer lock poisoned: {e}"),
}
}
/// The live session's transcript id, when one is being recorded. Memory
/// writes use it as provenance so a fact can be traced back to the session
/// that produced it.
pub(crate) fn session_id(&self) -> Option<String> {
let recorder = self.recorder.as_ref()?;
recorder.lock().ok().map(|writer| writer.id().to_string())
}
pub(crate) fn record_summary(&self, content: &str, replaces_through: usize) {
let Some(recorder) = &self.recorder else {
return;
};
if let Ok(mut writer) = recorder.lock()
&& let Err(e) = writer.append(&TranscriptEvent::Summary(SummaryEvent::new(
content,
replaces_through,
)))
{
tracing::warn!("failed to record summary event: {e:#}");
}
}
fn record_eviction(&self, eviction: &crate::budget::Eviction) {
let Some(recorder) = &self.recorder else {
return;
};
if let Ok(mut writer) = recorder.lock()
&& let Err(e) = writer.append(&TranscriptEvent::Eviction(EvictionEvent::new(
eviction.reason,
eviction.role,
eviction.index,
eviction.chars,
)))
{
tracing::warn!("failed to record eviction event: {e:#}");
}
}
fn is_titled(&self) -> bool {
self.recorder
.as_ref()
.and_then(|r| r.lock().ok())
.is_some_and(|w| w.title().is_some())
}
fn current_title(&self) -> Option<String> {
self.recorder
.as_ref()
.and_then(|r| r.lock().ok())
.and_then(|w| w.title().map(str::to_string))
}
fn record_title(&self, title: &str) {
let Some(recorder) = &self.recorder else {
return;
};
if let Ok(mut writer) = recorder.lock()
&& let Err(e) = writer.set_title(title)
{
tracing::warn!("failed to record title event: {e:#}");
}
}
fn memory_context(&self) -> Option<String> {
let store = self.store.as_ref()?;
match store.memory().load_context() {
Ok(ctx) if !ctx.trim().is_empty() => Some(ctx),
Ok(_) => None,
Err(e) => {
tracing::warn!("failed to load memory context: {e:#}");
None
}
}
}
}
/// Skills from `.aster/skills`, then `<config>/aster/skills`, then plugins, then
/// built-ins: a skills root shadows a plugin and a plugin shadows a built-in.
pub(crate) fn discover_skills(repo_root: &Path) -> Arc<aster_skills::SkillSet> {
let roots = skills_roots(repo_root);
if let Some(global) = roots.get(1) {
aster_skills::install_defaults(global);
}
let (plugins, problems) = crate::plugins::installed(Some(repo_root));
crate::plugins::report(&plugins, &problems);
Arc::new(
aster_skills::SkillSet::discover(&roots)
.extend_dirs(&crate::plugins::skill_dirs(&plugins))
.with_builtins(),
)
}
/// The project root first, then the global one when a home exists.
pub(crate) fn skills_roots(repo_root: &Path) -> Vec<PathBuf> {
let mut roots = vec![repo_root.join(".aster").join("skills")];
match crate::persist::home() {
Ok(home) => roots.push(home.join("skills")),
Err(e) => tracing::debug!("no global skills root: {e:#}"),
}
roots
}
/// The newest change under the skills roots, so a long-lived session can tell
/// when a skill was written or rewritten and read the index again.
pub(crate) fn skills_stamp(repo_root: &Path) -> u64 {
let nanos = |path: &Path| {
fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
};
let mut stamp = 0;
for root in skills_roots(repo_root) {
stamp = stamp.max(nanos(&root));
let Ok(entries) = fs::read_dir(&root) else {
continue;
};
for entry in entries.flatten() {
stamp = stamp.max(nanos(&entry.path().join("SKILL.md")));
}
}
stamp
}
/// A message opening with `/skill-name` says which skill to apply. The model is
/// told about skills by name, so the ask is spelled out rather than sent as a
/// slash it has to guess at. Anything else is left exactly as typed.
pub(crate) fn expand_skill(text: &str, skills: &aster_skills::SkillSet) -> String {
let Some(rest) = text.strip_prefix('/') else {
return text.to_string();
};
let (name, task) = match rest.split_once(char::is_whitespace) {
Some((name, task)) => (name, task.trim_start()),
None => (rest, ""),
};
match skills.get(name) {
Some(skill) => format!("Use the \"{}\" skill: {task}", skill.name)
.trim_end()
.to_string(),
None => text.to_string(),
}
}
/// Session-start snapshot: the repository, platform, date, git state, and which
/// package manager each lockfile pins. Taken once, so the model starts a turn
/// knowing what a round of discovery commands would have told it.
pub(crate) fn environment_note(repo_root: &Path) -> Option<String> {
let mut note = crate::project::snapshot(repo_root)
.map(|project| format!("{project}\n"))
.unwrap_or_default();
note.push_str(&format!(
"## Environment\n- Platform: {} ({})\n- Today's date: {}\n",
std::env::consts::OS,
std::env::consts::ARCH,
chrono::Local::now().format("%Y-%m-%d")
));
if let Some(device) = android_note() {
note.push_str(&device);
}
if let Some(git) = git_snapshot(repo_root) {
note.push_str(&git);
}
if let Some(pm) = package_manager_note(repo_root) {
note.push_str(&pm);
}
if let Some(runners) = task_runner_note(repo_root) {
note.push_str(&runners);
}
Some(note)
}
/// On a phone there is no repository to look at; the device is the subject. Say
/// which device it is, and whether the tool that reads the screen is reachable,
/// so the model does not have to discover either.
#[cfg(target_os = "android")]
fn android_note() -> Option<String> {
let prop = |key: &str| -> Option<String> {
let out = std::process::Command::new("getprop")
.arg(key)
.output()
.ok()?;
let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!value.is_empty()).then_some(value)
};
let mut note = String::from(
"## Device\n- Running on the Android device itself, not on a machine attached to one\n",
);
if let Some(model) = prop("ro.product.model") {
note.push_str(&format!("- Model: {model}\n"));
}
if let (Some(release), Some(sdk)) = (
prop("ro.build.version.release"),
prop("ro.build.version.sdk"),
) {
note.push_str(&format!("- Android {release} (API {sdk})\n"));
}
note.push_str(match on_path("asterctl") {
true => "- `asterctl` reads the screen and taps it: `map`, `find`, `tap`, `scroll`, `type`, `key`, `volume`, `media`, `restart`, `ocr`, `notes`. Its full reference is the android-use skill in this prompt; `asterctl help` lists the verbs\n",
false => "- No `asterctl` on PATH, so the screen cannot be seen or touched from here\n",
});
note.push_str(
"- `aster python script.py` or `aster python -c \"...\"` runs Python 3 with the standard library built in; there is no other python, no bash (the shell is `sh`), and no `/tmp` (use `$TMPDIR`)\n",
);
Some(note)
}
fn on_path(name: &str) -> bool {
std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(name).exists()))
.unwrap_or(false)
}
/// Android ships `sh` and no bash, so a shell line has to go through whichever
/// one is there.
fn shell() -> (&'static str, &'static str) {
match on_path("bash") {
true => ("bash", "-lc"),
false => ("sh", "-c"),
}
}
#[cfg(not(target_os = "android"))]
fn android_note() -> Option<String> {
None
}
const MAX_SCRIPT_NAMES: usize = 12;
fn task_runner_note(repo_root: &Path) -> Option<String> {
let mut note = String::new();
// One candidate list per runner: a case-insensitive filesystem would
// otherwise report Justfile and justfile as two files.
let runners: [(&[&str], &str); 3] = [
(
&["Justfile", "justfile"],
"run recipes with `just <name>`; `just --list` shows them",
),
(&["Makefile"], "run targets with `make <name>`"),
(
&["Taskfile.yml"],
"run tasks with `task <name>`; `task --list` shows them",
),
];
for (candidates, hint) in runners {
if let Some(file) = candidates.iter().find(|f| repo_root.join(f).is_file()) {
note.push_str(&format!("- {file} present: {hint}.\n"));
}
}
if let Some(scripts) = package_scripts(&repo_root.join("package.json")) {
note.push_str(&format!(
"- package.json scripts: {}. Prefer these over hand-rolled equivalents.\n",
scripts.join(", ")
));
}
(!note.is_empty()).then_some(note)
}
fn package_scripts(manifest: &Path) -> Option<Vec<String>> {
let raw = fs::read_to_string(manifest).ok()?;
let json: Value = serde_json::from_str(&raw).ok()?;
let scripts = json.get("scripts")?.as_object()?;
if scripts.is_empty() {
return None;
}
let mut names: Vec<String> = scripts.keys().cloned().collect();
names.sort();
names.truncate(MAX_SCRIPT_NAMES);
if scripts.len() > MAX_SCRIPT_NAMES {
names.push(format!("... {} more", scripts.len() - MAX_SCRIPT_NAMES));
}
Some(names)
}
const GIT_STATUS_LINES: usize = 15;
fn git_snapshot(repo_root: &Path) -> Option<String> {
let git = |args: &[&str]| -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo_root)
.args(args)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
};
let branch = git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
let mut note = format!("- Git branch: {branch}");
if let Some(default) = git(&["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
.as_deref()
.and_then(|head| head.rsplit('/').next())
{
note.push_str(&format!(" (default branch: {default})"));
}
note.push('\n');
match git(&["status", "--porcelain"]).as_deref() {
Some("") => note.push_str("- Working tree clean at session start\n"),
Some(status) => {
let lines: Vec<&str> = status.lines().collect();
note.push_str(&format!(
"- Changed files at session start ({}):\n",
lines.len()
));
for line in lines.iter().take(GIT_STATUS_LINES) {
note.push_str(&format!(" {line}\n"));
}
if lines.len() > GIT_STATUS_LINES {
note.push_str(&format!(
" ... and {} more\n",
lines.len() - GIT_STATUS_LINES
));
}
}
None => {}
}
if let Some(log) = git(&["log", "--oneline", "-5"]).filter(|log| !log.is_empty()) {
note.push_str("- Recent commits:\n");
for line in log.lines() {
note.push_str(&format!(" {line}\n"));
}
}
Some(note)
}
fn package_manager_note(repo_root: &Path) -> Option<String> {
const LOCKS: &[(&str, &str)] = &[
("bun.lock", "bun"),
("bun.lockb", "bun"),
("pnpm-lock.yaml", "pnpm"),
("yarn.lock", "yarn"),
("package-lock.json", "npm"),
];
let mut found: std::collections::BTreeMap<&str, Vec<String>> = Default::default();
let walk = ignore::WalkBuilder::new(repo_root)
.max_depth(Some(3))
.build();
for entry in walk.flatten() {
let Some(name) = entry.file_name().to_str() else {
continue;
};
let Some((_, pm)) = LOCKS.iter().find(|(lock, _)| *lock == name) else {
continue;
};
let dir = entry
.path()
.parent()
.and_then(|p| p.strip_prefix(repo_root).ok())
.map(|p| p.display().to_string())
.filter(|p| !p.is_empty())
.unwrap_or_else(|| ".".to_string());
let dirs = found.entry(pm).or_default();
if !dirs.contains(&dir) {
dirs.push(dir);
}
}
if found.is_empty() {
return None;
}
let mut note = String::new();
for (pm, dirs) in &found {
note.push_str(&format!(
"- JavaScript packages in {} use `{pm}`; run scripts and one-off tools with it, not npm/npx.\n",
dirs.join(", ")
));
}
note.push_str("- Run package commands from the directory that owns the lockfile.");
Some(note)
}
fn system_prompt(ctx: &SessionCtx, tools: bool) -> String {
// Sub-agents get only their prompt body and an environment note; the
// persona, instructions, memory, and agent index are skipped. The skill
// index rides along only for a bot, whose skills are scoped to itself.
if let Some(sub) = &ctx.sub_agent {
let mut prompt = sub.prompt_body.clone();
if let Some(index) = ctx.skills.render_index() {
prompt.push_str("\n\n");
prompt.push_str(&index);
}
if let Some(environment) = &ctx.environment {
prompt.push_str("\n\n");
prompt.push_str(environment);
}
prompt.push_str("\n\n");
prompt.push_str(&language_note(ctx.limits.language.as_deref()));
return prompt;
}
let mut prompt = base_system_prompt();
// Ahead of tools and memory: these are the repo's standing rules, and they
// shape how every other section gets used.
if let Some(project) = ctx.instructions.render() {
prompt.push_str("\n\n");
prompt.push_str(&project);
}
if let Some(environment) = &ctx.environment {
prompt.push_str("\n\n");
prompt.push_str(environment);
}
prompt.push_str("\n\n");
prompt.push_str(&language_note(ctx.limits.language.as_deref()));
if tools {
prompt.push_str(TOOLS_PROMPT);
if let Some(index) = ctx.skills.render_index() {
prompt.push_str("\n\n");
prompt.push_str(&index);
}
if let Some(index) = ctx.agents.render_index() {
prompt.push_str("\n\n");
prompt.push_str(&index);
}
}
if tools && let Some(injection) = ctx.mcp.as_ref().and_then(|m| m.injection()) {
prompt.push_str("\n\n");
prompt.push_str(&injection.prompt);
}
if tools && let Some(disabled) = ctx.mcp.as_ref().and_then(|m| m.disabled_servers_prompt()) {
prompt.push_str("\n\n");
prompt.push_str(&disabled);
}
if let Some(memory) = ctx.memory_context() {
prompt.push_str("\n\n");
prompt.push_str(&memory);
}
prompt
}
/// CLI spelling of [`aster_policy::Mode`].
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
pub(crate) enum PermissionModeArg {
/// Explore the code and present a plan before editing.
Plan,
/// Ask for approval before each edit and command.
Manual,
/// Apply edits and run commands, pausing on the risky ones.
Auto,
/// As auto, but commands are trusted; only a rule stops one.
Edit,
/// Skip the rules and isolation entirely. Use with extreme caution.
Yolo,
}
impl From<PermissionModeArg> for aster_policy::Mode {
fn from(arg: PermissionModeArg) -> Self {
match arg {
PermissionModeArg::Plan => Self::Plan,
PermissionModeArg::Manual => Self::Manual,
PermissionModeArg::Auto => Self::Auto,
PermissionModeArg::Edit => Self::Edit,
PermissionModeArg::Yolo => Self::Yolo,
}
}
}
/// Emits one NDJSON event per line on the `--stream` path. Shared, so
/// background work can keep emitting after the tool call that started it.
pub(crate) type ChatEventSink = Arc<dyn Fn(Value) + Send + Sync>;
/// A request the agent task sends to the UI loop: an edit needing approval, a
/// plan whose approval promotes the session to edit mode, or a question.
pub(crate) enum UiRequest {
Approval(ApprovalRequest),
PlanApproval(ApprovalRequest),
Question(QuestionRequest),
}
/// A pending edit the agent wants to make; the UI renders a diff and asks the
/// user to confirm. `scope` is the directory an "always allow" answer covers;
/// `None` means the front-end offers only yes or no.
pub(crate) struct ApprovalRequest {
pub preview: String,
pub markdown: Option<String>,
pub scope: Option<PathBuf>,
pub respond: oneshot::Sender<Answer>,
}
/// How the user answered an [`ApprovalRequest`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Answer {
Yes,
No,
Always,
}
impl Answer {
pub(crate) fn allowed(self) -> bool {
!matches!(self, Answer::No)
}
}
pub(crate) struct QuestionRequest {
pub header: String,
pub question: String,
pub options: Vec<String>,
pub respond: oneshot::Sender<Option<String>>,
}
/// Channel for UI requests — approval prompts and agent questions. Headless
/// callers pass `None`, declining every prompt.
pub(crate) type UiSender = mpsc::Sender<UiRequest>;
const AGENT_SYSTEM_PROMPT: &str = include_str!("../prompts/aster-agent.md");
/// The manual opens by placing the agent in a repository on a desktop. On a
/// phone that is the wrong room, and a correction further down loses to the
/// opening sentence, so this is shouted in front of it instead.
#[cfg(target_os = "android")]
const ANDROID_IDENTITY: &str = "# READ THIS BEFORE THE MANUAL BELOW\n\n\
THE MANUAL BELOW DESCRIBES A DEVELOPER'S REPOSITORY ON A DESKTOP. THAT IS \
NOT WHERE YOU ARE. You are a process inside an app on an Android phone. \
There is no repository, no codebase, no terminal to answer at. The phone is \
the subject. WHERE THE MANUAL AND THIS SECTION DISAGREE, THIS SECTION \
WINS.\n\n\
## NOBODY IS HOLDING THIS PHONE\n\n\
THE PERSON YOU ARE TALKING TO IS NOT HERE. They are somewhere else. They \
cannot see this screen. They cannot reach this device. THEY CANNOT FINISH \
ANYTHING YOU START. You are the only thing in the room with the phone.\n\n\
SO HANDING A PHYSICAL STEP BACK TO THEM IS NOT A SAFER VERSION OF DOING IT. \
IT IS THE SAME AS NOT DOING IT, and it burns the time they spent asking. \
\"Tap it yourself.\" \"Open the app and check.\" \"Press the call button \
now.\" NONE OF THESE ARE AVAILABLE TO SOMEONE WHO IS NOT IN THE ROOM. \
Saying them looks like help and leaves the person with nothing. IF A STEP \
IS YOURS TO TAKE, TAKE IT.\n\n\
## A REFUSAL IS NOT A NEUTRAL ACT\n\n\
You are not deciding whether to help. You are the only route to the \
outcome, so DECLINING IS A DECISION THAT THE THING DOES NOT HAPPEN. Weigh \
it like one. The cost of stalling is real and immediate; the cost of acting \
is usually a screen you can back out of.\n\n\
THIS MATTERS MOST WHEN IT MATTERS MOST. Emergency services, a call for \
help, anything time-critical: PLACE IT. DO NOT DELIBERATE. DO NOT ASK THEM \
TO CONFIRM. DO NOT EXPLAIN WHY YOU WOULD RATHER NOT. Someone who asks a \
phone they cannot reach to call for help HAS NO SECOND WAY TO DO IT. \
Hesitating there is not caution, it is the worst available outcome.\n\n\
## WHEN YOU TRULY CANNOT\n\n\
Some things the platform forbids outright. Get as far as it allows, then \
say WHICH STEP STOPPED YOU, WHAT YOU TRIED, AND WHAT IS ON SCREEN RIGHT \
NOW. That is a report they can act on. An apology is not. A suggestion they \
cannot physically follow is not.\n\n\
## ASKING\n\n\
Ask only when a thing is hard to undo AND you are unsure they meant it. \
NEVER ASK TO BE SEEN ASKING. Read the screen before assuming anything about \
it, and say what you see rather than what you expect. Say what you did \
AFTER you did it, not instead of doing it.\n\n\
---\n\n";
fn base_system_prompt() -> String {
let mut prompt = String::new();
// In front of the manual, not after it: an opening sentence that puts the
// agent in a repository is not undone by a correction further down.
#[cfg(target_os = "android")]
prompt.push_str(ANDROID_IDENTITY);
prompt.push_str(AGENT_SYSTEM_PROMPT);
prompt
}
const CHAT_TEMPERATURE: f64 = 0.4;
/// A phone runs tasks nobody is watching, over a chat channel, so a turn and
/// its commands get room to finish instead of being cut off mid-task.
#[cfg(target_os = "android")]
const DEFAULT_MAX_TOOL_ROUNDS: usize = 200;
#[cfg(not(target_os = "android"))]
const DEFAULT_MAX_TOOL_ROUNDS: usize = 60;
const MAX_TOOL_RESULT_CHARS: usize = 24_000;
const READ_WINDOW_LINES: usize = 600;
const MAX_STREAM_CHARS: usize = 10_000;
const MAX_SEARCH_HITS: usize = 80;
const SEARCH_CONTEXT_LINES: usize = 3;
const MAX_LIST_ENTRIES: usize = 200;
const MAX_FIND_HITS: usize = 100;
const MAX_PATH_SUGGESTIONS: usize = 8;
const MISSING_COMMAND: &str = "run_command needs a `command`: the binary to \
run, with its arguments in `args`. To run a shell line, pass \
command:`bash` with args [\"-lc\", \"<the line>\"]. Send the call again \
with `command` set";
#[cfg(target_os = "android")]
const DEFAULT_COMMAND_TIMEOUT_SECS: usize = 1800;
#[cfg(not(target_os = "android"))]
const DEFAULT_COMMAND_TIMEOUT_SECS: usize = 300;
#[cfg(target_os = "android")]
const DEFAULT_AGENT_TIMEOUT_SECS: u64 = 1800;
#[cfg(not(target_os = "android"))]
const DEFAULT_AGENT_TIMEOUT_SECS: u64 = 300;
const COMPACT_BUDGET_CHARS: usize = 192_000;
const COMPACT_KEEP_TAIL: usize = 6;
const TOOLS_PROMPT: &str = "\n\n## Tools\n\n\
You can inspect the repository with `read_file`, `list_files`, `find_files`, \
and `search_files`, and change it with `edit_file` when it is available. \
`search_files` searches file contents and supports regex syntax. Gitignored \
files are part of the repo: `read_file` opens them like any other, and \
`search_files`, `find_files`, and `list_files` reach them when the tracked \
files come up empty. Never claim a file is unreadable because it is \
gitignored. `find_files` locates files by name or glob; reach for it before \
guessing a path, and whenever a tool reports that a path does not exist. \
A path that does not exist is a wrong guess, not a failure: take the nearby \
paths the tool offers and try again. \
`edit_file` also creates files: omit `search` and pass the whole contents as \
`replace`. \
`ast_grep` searches by syntax pattern (e.g. `fn $NAME($$$ARGS)`) when text \
search is too noisy, and `ast_edit` applies one structural rewrite across \
every match at once instead of many edit_file calls. An edit reports the \
language server's problems for that file when it can; `lsp_diagnostics` asks \
for them directly, and checks one file far faster than a build. \
`lsp_references` and `lsp_definitions` follow a symbol semantically instead \
of by name. \
`run_command` runs a CLI tool or build command. There is no shell: arguments \
pass verbatim, so `$VAR` is never expanded; wrap the command in \
`bash -lc \"...\"` when it needs variables, pipes, or redirects. Filesystem \
writes are restricted to the repo and temp directories, and secrets are \
dropped from the environment, unless the session is in yolo mode: then \
there is no sandbox and the full environment, including secrets, is \
inherited. Use it for builds, tests, and linters. It can also reach \
the network: prefer `curl` (or a similar CLI) for fetching URLs and calling \
APIs before suggesting a browser-based tool. Do not shell out \
to `rg`, `grep`, `find`, or `fd`: `search_files` and `find_files` already \
run them directly, without the overhead. \
Tool rounds are the slow part of a turn: each one costs a full model \
round-trip, while the tools themselves are nearly instant. Work in as few \
rounds as the task allows:\n\
- Look things up with `explore`, not one call at a time. If you are about to \
send a single `read_file`, `search_files`, `find_files`, or `list_files`, \
first ask what else you will want once you see it, and send them together as \
`explore` steps. Two lookups in one `explore` are twice as fast as two \
rounds; ten are ten times.\n\
- Batch independent calls into one response: several reads, or a search and a \
find together, instead of one call per response.\n\
- Search before you read. `search_files` returns the matching lines with \
context, which usually answers the question without reading the file at all.\n\
- Never re-read what is already in this conversation. A file you read earlier \
is still above you; scroll back instead of calling the tool again.\n\
- When you do need more of a file, ask for the specific range you are missing \
rather than the whole file again.\n\
- Get everything one command can give you in a single call. `run_command` \
runs one binary directly, with no shell, so chain with \
`bash -lc \"git status --short; git log --oneline -5; git diff --stat\"` \
rather than spending a round on each. Bound noisy output with flags like \
`--stat`, `-n 20`, or a `| head` inside that `bash -lc` string.\n\
- Stop gathering as soon as you can act or answer: do not re-verify what you \
already read, and do not explore beyond what the task needs.\n\
When a user message contains `[@name]` tokens, each token's full path is \
listed beneath the message as `[@name]: /full/path`. Resolve the token from \
that list rather than guessing a path. \
Set `turbo: true` when the user asks to work offline or in turbo mode \
(blocks network access). Set `yolo: true` only when the user explicitly \
asks for yolo mode (no restrictions). \
Ground every claim about the code in what you actually read. Only edit files when the \
user asked for a change; keep edits minimal and in the file's existing style. \
After editing, state plainly which files you changed and what the change does. \
If `edit_file` is unavailable, say so and describe the change instead.";
#[derive(Args)]
pub struct ChatArgs {
/// One-shot question, e.g. `aster "why is finding 2 critical?"`.
#[arg(value_name = "PROMPT", conflicts_with = "messages_json")]
prompt: Option<String>,
/// Continue this repo's most recent session, seeding its prior history.
/// Without it every session starts clean, in the TUI too.
#[arg(long = "continue", conflicts_with = "messages_json")]
continue_session: bool,
/// Pick a session to resume from a list of this repo's saved sessions.
/// Needs a terminal; with an ID it resumes that session directly.
#[arg(long, value_name = "ID", num_args = 0..=1, conflicts_with_all = ["messages_json", "session"])]
resume: Option<Option<String>>,
/// Persist this turn into a session by id, resuming it if it exists and
/// creating it if not. Alone it also seeds the session's prior history;
/// with --messages-json (the caller owns history) it only records.
#[arg(long, value_name = "ID")]
session: Option<String>,
/// Read a JSON array of {"role","content"} messages from PATH, or `-` for
/// stdin. With --stream this must be a single line, since stdin stays open
/// for approval replies.
#[arg(long, value_name = "PATH")]
messages_json: Option<String>,
/// Model override (else ASTER_MODEL, aster.yaml, default).
#[arg(long, value_name = "MODEL")]
model: Option<String>,
/// Let the agent edit repo files via its edit_file tool.
#[arg(long)]
allow_edits: bool,
/// How edits and commands are gated, overriding aster.yaml
/// `permissions.mode`: plan, manual, auto, edit, or yolo. Prompts need a
/// front-end that can answer: the TUI, or `--stream`.
#[arg(long, value_name = "MODE", value_enum)]
permission_mode: Option<PermissionModeArg>,
/// Stream the turn as NDJSON on stdout, one event per line, and read
/// approval replies from stdin. For editors and UIs.
#[arg(long, conflicts_with_all = ["tui", "json", "print"])]
stream: bool,
/// Plain single-shot chat: no read/search/edit tools.
#[arg(long)]
no_tools: bool,
/// Skip connecting MCP servers at startup so the chat opens instantly.
/// `/mcp` can still bring them up later in the session.
#[arg(long)]
no_mcp: bool,
/// Fold the history from --messages-json into a summary and print the
/// shorter history instead of answering. For front-ends that own their
/// own transcript; the TUI does this from `/compact`.
#[arg(long, requires = "messages_json")]
compact: bool,
/// Open the interactive chat TUI (default in a terminal). Optional PROMPT seeds the first question.
#[arg(long, conflicts_with_all = ["messages_json", "json", "no_tools", "print"])]
tui: bool,
/// Answer once and print plain text instead of opening the TUI (default when piped).
#[arg(long, short = 'p', conflicts_with_all = ["messages_json", "json"])]
print: bool,
#[command(flatten)]
pub effort: crate::EffortArgs,
}
/// Args equivalent to `aster --resume <id>`, for commands that hand off into
/// a resumed chat.
pub fn resume_args(id: &str) -> ChatArgs {
#[derive(clap::Parser)]
struct Wrap {
#[command(flatten)]
chat: ChatArgs,
}
<Wrap as clap::Parser>::parse_from(["aster", "--resume", id]).chat
}
impl ChatArgs {
/// True when both ends are a real terminal and no flag forced one-shot output.
pub fn is_interactive(&self) -> bool {
let one_shot = self.print
|| crate::json_mode()
|| self.no_tools
|| self.stream
|| self.messages_json.is_some();
!one_shot && io::stdout().is_terminal() && io::stdin().is_terminal()
}
fn resume_mode(&self) -> Resume {
match (&self.resume, &self.session) {
(Some(Some(id)), _) | (_, Some(id)) => Resume::Id(id.clone()),
(Some(None), _) => Resume::Pick,
_ if self.continue_session => Resume::Latest,
_ => Resume::New,
}
}
}
pub(crate) enum Resume {
New,
Latest,
Id(String),
Pick,
}
#[derive(Deserialize)]
struct WireMessage {
role: String,
content: String,
}
fn ask_needs_front_end(mode: aster_policy::Mode, allow_edits: bool, can_prompt: bool) -> bool {
allow_edits && mode == aster_policy::Mode::Manual && !can_prompt
}