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//! Centralized system prompt builder.
//!
//! Assembles a structured, multi-section system prompt from agent context.
//! Replaces the scattered `push_str` prompt injection throughout the codebase
//! with a single, testable, ordered prompt builder.
/// All the context needed to build a system prompt for an agent.
#[derive(Debug, Clone, Default)]
pub struct PromptContext {
/// Agent name (from manifest).
pub agent_name: String,
/// Agent description (from manifest).
pub agent_description: String,
/// Base system prompt authored in the agent manifest.
pub base_system_prompt: String,
/// Tool names this agent has access to.
pub granted_tools: Vec<String>,
/// Recalled memories as (key, content) pairs.
pub recalled_memories: Vec<(String, String)>,
/// Skill summary text (from kernel.build_skill_summary()).
pub skill_summary: String,
/// Prompt context from prompt-only skills.
pub skill_prompt_context: String,
/// MCP server/tool summary text.
pub mcp_summary: String,
/// Agent workspace path.
pub workspace_path: Option<String>,
/// SOUL.md content (persona).
pub soul_md: Option<String>,
/// USER.md content.
pub user_md: Option<String>,
/// MEMORY.md content.
pub memory_md: Option<String>,
/// Cross-channel canonical context summary.
pub canonical_context: Option<String>,
/// Known user name (from shared memory).
pub user_name: Option<String>,
/// Channel type (telegram, discord, web, etc.).
pub channel_type: Option<String>,
/// Whether this agent was spawned as a subagent.
pub is_subagent: bool,
/// Whether this agent has autonomous config.
pub is_autonomous: bool,
/// AGENTS.md content (behavioral guidance).
pub agents_md: Option<String>,
/// BOOTSTRAP.md content (first-run ritual).
pub bootstrap_md: Option<String>,
/// Workspace context section (project type, context files).
pub workspace_context: Option<String>,
/// IDENTITY.md content (visual identity + personality frontmatter).
pub identity_md: Option<String>,
/// HEARTBEAT.md content (autonomous agent checklist).
pub heartbeat_md: Option<String>,
/// Peer agents visible to this agent: (name, state, model).
pub peer_agents: Vec<(String, String, String)>,
}
/// Build the complete system prompt from a `PromptContext`.
///
/// Produces an ordered, multi-section prompt. Sections with no content are
/// omitted entirely (no empty headers). Subagent mode skips sections that
/// add unnecessary context overhead.
pub fn build_system_prompt(ctx: &PromptContext) -> String {
let mut sections: Vec<String> = Vec::with_capacity(12);
// Section 1 β Agent Identity (always present)
sections.push(build_identity_section(ctx));
// Section 2 β Tool Call Behavior (skip for subagents)
if !ctx.is_subagent {
sections.push(TOOL_CALL_BEHAVIOR.to_string());
}
// Section 2.5 β Agent Behavioral Guidelines (skip for subagents)
if !ctx.is_subagent {
if let Some(ref agents) = ctx.agents_md {
if !agents.trim().is_empty() {
sections.push(cap_str(agents, 2000));
}
}
}
// Section 3 β Available Tools (always present if tools exist)
let tools_section = build_tools_section(&ctx.granted_tools);
if !tools_section.is_empty() {
sections.push(tools_section);
}
// Section 4 β Memory Protocol (always present)
let mem_section = build_memory_section(&ctx.recalled_memories);
sections.push(mem_section);
// Section 5 β Skills (only if skills available)
if !ctx.skill_summary.is_empty() || !ctx.skill_prompt_context.is_empty() {
sections.push(build_skills_section(
&ctx.skill_summary,
&ctx.skill_prompt_context,
));
}
// Section 6 β MCP Servers (only if summary present)
if !ctx.mcp_summary.is_empty() {
sections.push(build_mcp_section(&ctx.mcp_summary));
}
// Section 7 β Persona / Identity files (skip for subagents)
if !ctx.is_subagent {
let persona = build_persona_section(
ctx.identity_md.as_deref(),
ctx.soul_md.as_deref(),
ctx.user_md.as_deref(),
ctx.memory_md.as_deref(),
ctx.workspace_path.as_deref(),
);
if !persona.is_empty() {
sections.push(persona);
}
}
// Section 7.5 β Heartbeat checklist (only for autonomous agents)
if !ctx.is_subagent && ctx.is_autonomous {
if let Some(ref heartbeat) = ctx.heartbeat_md {
if !heartbeat.trim().is_empty() {
sections.push(format!(
"## Heartbeat Checklist\n{}",
cap_str(heartbeat, 1000)
));
}
}
}
// Section 8 β User Personalization (skip for subagents)
if !ctx.is_subagent {
sections.push(build_user_section(ctx.user_name.as_deref()));
}
// Section 9 β Channel Awareness (skip for subagents)
if !ctx.is_subagent {
if let Some(ref channel) = ctx.channel_type {
sections.push(build_channel_section(channel));
}
}
// Section 9.5 β Peer Agent Awareness (skip for subagents)
if !ctx.is_subagent && !ctx.peer_agents.is_empty() {
sections.push(build_peer_agents_section(&ctx.agent_name, &ctx.peer_agents));
}
// Section 10 β Safety & Oversight (skip for subagents)
if !ctx.is_subagent {
sections.push(SAFETY_SECTION.to_string());
}
// Section 11 β Operational Guidelines (always present)
sections.push(OPERATIONAL_GUIDELINES.to_string());
// Section 12 β Canonical Context moved to build_canonical_context_message()
// to keep the system prompt stable across turns for provider prompt caching.
// Section 13 β Bootstrap Protocol (only on first-run, skip for subagents)
if !ctx.is_subagent {
if let Some(ref bootstrap) = ctx.bootstrap_md {
if !bootstrap.trim().is_empty() {
// Only inject if no user_name memory exists (first-run heuristic)
let has_user_name = ctx.recalled_memories.iter().any(|(k, _)| k == "user_name");
if !has_user_name && ctx.user_name.is_none() {
sections.push(format!(
"## First-Run Protocol\n{}",
cap_str(bootstrap, 1500)
));
}
}
}
}
// Section 14 β Workspace Context (skip for subagents)
if !ctx.is_subagent {
if let Some(ref ws_ctx) = ctx.workspace_context {
if !ws_ctx.trim().is_empty() {
sections.push(cap_str(ws_ctx, 1000));
}
}
}
sections.join("\n\n")
}
// ---------------------------------------------------------------------------
// Section builders
// ---------------------------------------------------------------------------
fn build_identity_section(ctx: &PromptContext) -> String {
if ctx.base_system_prompt.is_empty() {
format!(
"You are {}, an AI agent running inside the OpenFang Agent OS.\n{}",
ctx.agent_name, ctx.agent_description
)
} else {
ctx.base_system_prompt.clone()
}
}
/// Static tool-call behavior directives.
const TOOL_CALL_BEHAVIOR: &str = "\
## Tool Call Behavior
- When you need to use a tool, call it immediately. Do not narrate or explain routine tool calls.
- Only explain tool calls when the action is destructive, unusual, or the user explicitly asked for an explanation.
- Prefer action over narration. If you can answer by using a tool, do it.
- When executing multiple sequential tool calls, batch them β don't output reasoning between each call.
- If a tool returns useful results, present the KEY information, not the raw output.
- Start with the answer, not meta-commentary about how you'll help.";
/// Build the grouped tools section (Section 3).
pub fn build_tools_section(granted_tools: &[String]) -> String {
if granted_tools.is_empty() {
return String::new();
}
// Group tools by category
let mut groups: std::collections::BTreeMap<&str, Vec<(&str, &str)>> =
std::collections::BTreeMap::new();
for name in granted_tools {
let cat = tool_category(name);
let hint = tool_hint(name);
groups.entry(cat).or_default().push((name.as_str(), hint));
}
let mut out = String::from("## Your Tools\nYou have access to these capabilities:\n");
for (category, tools) in &groups {
out.push_str(&format!("\n**{}**: ", capitalize(category)));
let descs: Vec<String> = tools
.iter()
.map(|(name, hint)| {
if hint.is_empty() {
(*name).to_string()
} else {
format!("{name} ({hint})")
}
})
.collect();
out.push_str(&descs.join(", "));
}
out
}
/// Build canonical context as a standalone user message (instead of system prompt).
///
/// This keeps the system prompt stable across turns, enabling provider prompt caching
/// (Anthropic cache_control, etc.). The canonical context changes every turn, so
/// injecting it in the system prompt caused 82%+ cache misses.
pub fn build_canonical_context_message(ctx: &PromptContext) -> Option<String> {
if ctx.is_subagent {
return None;
}
ctx.canonical_context
.as_ref()
.filter(|c| !c.is_empty())
.map(|c| format!("[Previous conversation context]\n{}", cap_str(c, 500)))
}
/// Build the memory section (Section 4).
///
/// Also used by `agent_loop.rs` to append recalled memories after DB lookup.
pub fn build_memory_section(memories: &[(String, String)]) -> String {
let mut out = String::from(
"## Memory\n\
- When the user asks about something from a previous conversation, use memory_recall first.\n\
- Store important preferences, decisions, and context with memory_store for future use.",
);
if !memories.is_empty() {
out.push_str("\n\nRecalled memories:\n");
for (key, content) in memories.iter().take(5) {
let capped = cap_str(content, 500);
if key.is_empty() {
out.push_str(&format!("- {capped}\n"));
} else {
out.push_str(&format!("- [{key}] {capped}\n"));
}
}
}
out
}
fn build_skills_section(skill_summary: &str, prompt_context: &str) -> String {
let mut out = String::from("## Skills\n");
if !skill_summary.is_empty() {
out.push_str(
"You have installed skills. If a request matches a skill, use its tools directly.\n",
);
out.push_str(skill_summary.trim());
}
if !prompt_context.is_empty() {
out.push('\n');
out.push_str(&cap_str(prompt_context, 2000));
}
out
}
fn build_mcp_section(mcp_summary: &str) -> String {
format!("## Connected Tool Servers (MCP)\n{}", mcp_summary.trim())
}
fn build_persona_section(
identity_md: Option<&str>,
soul_md: Option<&str>,
user_md: Option<&str>,
memory_md: Option<&str>,
workspace_path: Option<&str>,
) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(ws) = workspace_path {
parts.push(format!("## Workspace\nWorkspace: {ws}"));
}
// Identity file (IDENTITY.md) β personality at a glance, before SOUL.md
if let Some(identity) = identity_md {
if !identity.trim().is_empty() {
parts.push(format!("## Identity\n{}", cap_str(identity, 500)));
}
}
if let Some(soul) = soul_md {
if !soul.trim().is_empty() {
parts.push(format!(
"## Persona\nEmbody this identity in your tone and communication style. Be natural, not stiff or generic.\n{}",
cap_str(soul, 1000)
));
}
}
if let Some(user) = user_md {
if !user.trim().is_empty() {
parts.push(format!("## User Context\n{}", cap_str(user, 500)));
}
}
if let Some(memory) = memory_md {
if !memory.trim().is_empty() {
parts.push(format!("## Long-Term Memory\n{}", cap_str(memory, 500)));
}
}
parts.join("\n\n")
}
fn build_user_section(user_name: Option<&str>) -> String {
match user_name {
Some(name) => {
format!(
"## User Profile\n\
The user's name is \"{name}\". Address them by name naturally \
when appropriate (greetings, farewells, etc.), but don't overuse it."
)
}
None => "## User Profile\n\
You don't know the user's name yet. On your FIRST reply in this conversation, \
warmly introduce yourself by your agent name and ask what they'd like to be called. \
Once they tell you, immediately use the `memory_store` tool with \
key \"user_name\" and their name as the value so you remember it for future sessions. \
Keep the introduction brief β don't let it overshadow their actual request."
.to_string(),
}
}
fn build_channel_section(channel: &str) -> String {
let (limit, hints) = match channel {
"telegram" => (
"4096",
"Use Telegram-compatible formatting (bold with *, code with `backticks`).",
),
"discord" => (
"2000",
"Use Discord markdown. Split long responses across multiple messages if needed.",
),
"slack" => (
"4000",
"Use Slack mrkdwn formatting (*bold*, _italic_, `code`).",
),
"whatsapp" => (
"4096",
"Keep messages concise. WhatsApp has limited formatting.",
),
"irc" => (
"512",
"Keep messages very short. No markdown β plain text only.",
),
"matrix" => (
"65535",
"Matrix supports rich formatting. Use markdown freely.",
),
"teams" => ("28000", "Use Teams-compatible markdown."),
_ => ("4096", "Use markdown formatting where supported."),
};
format!(
"## Channel\n\
You are responding via {channel}. Keep messages under {limit} chars.\n\
{hints}"
)
}
fn build_peer_agents_section(self_name: &str, peers: &[(String, String, String)]) -> String {
let mut out = String::from(
"## Peer Agents\n\
You are part of a multi-agent system. These agents are running alongside you:\n",
);
for (name, state, model) in peers {
if name == self_name {
continue; // Don't list yourself
}
out.push_str(&format!("- **{}** ({}) β model: {}\n", name, state, model));
}
out.push_str(
"\nYou can communicate with them using `agent_send` (by name) and see all agents with `agent_list`. \
Delegate tasks to specialized agents when appropriate.",
);
out
}
/// Static safety section.
const SAFETY_SECTION: &str = "\
## Safety
- Prioritize safety and human oversight over task completion.
- NEVER auto-execute purchases, payments, account deletions, or irreversible actions without explicit user confirmation.
- If a tool could cause data loss, explain what it will do and confirm first.
- If you cannot accomplish a task safely, explain the limitation.
- When in doubt, ask the user.";
/// Static operational guidelines (replaces STABILITY_GUIDELINES).
const OPERATIONAL_GUIDELINES: &str = "\
## Operational Guidelines
- Do NOT retry a tool call with identical parameters if it failed. Try a different approach.
- If a tool returns an error, analyze the error before calling it again.
- Prefer targeted, specific tool calls over broad ones.
- Plan your approach before executing multiple tool calls.
- If you cannot accomplish a task after a few attempts, explain what went wrong instead of looping.
- Never call the same tool more than 3 times with the same parameters.
- If a message requires no response (simple acknowledgments, reactions, messages not directed at you), respond with exactly NO_REPLY.";
// ---------------------------------------------------------------------------
// Tool metadata helpers
// ---------------------------------------------------------------------------
/// Map a tool name to its category for grouping.
pub fn tool_category(name: &str) -> &'static str {
match name {
"file_read" | "file_write" | "file_list" | "file_delete" | "file_move" | "file_copy"
| "file_search" => "Files",
"web_search" | "web_fetch" => "Web",
"browser_navigate" | "browser_click" | "browser_type" | "browser_screenshot"
| "browser_read_page" | "browser_close" | "browser_scroll" | "browser_wait"
| "browser_evaluate" | "browser_select" | "browser_back" => "Browser",
"shell_exec" | "shell_background" => "Shell",
"memory_store" | "memory_recall" | "memory_delete" | "memory_list" => "Memory",
"agent_send" | "agent_spawn" | "agent_list" | "agent_kill" => "Agents",
"image_describe" | "image_generate" | "audio_transcribe" | "tts_speak" => "Media",
"docker_exec" | "docker_build" | "docker_run" => "Docker",
"cron_create" | "cron_list" | "cron_delete" => "Scheduling",
"process_start" | "process_poll" | "process_write" | "process_kill" | "process_list" => {
"Processes"
}
_ if name.starts_with("mcp_") => "MCP",
_ if name.starts_with("skill_") => "Skills",
_ => "Other",
}
}
/// Map a tool name to a one-line description hint.
pub fn tool_hint(name: &str) -> &'static str {
match name {
// Files
"file_read" => "read file contents",
"file_write" => "create or overwrite a file",
"file_list" => "list directory contents",
"file_delete" => "delete a file",
"file_move" => "move or rename a file",
"file_copy" => "copy a file",
"file_search" => "search files by name pattern",
// Web
"web_search" => "search the web for information",
"web_fetch" => "fetch a URL and get its content as markdown",
// Browser
"browser_navigate" => "open a URL in the browser",
"browser_click" => "click an element on the page",
"browser_type" => "type text into an input field",
"browser_screenshot" => "capture a screenshot",
"browser_read_page" => "extract page content as text",
"browser_close" => "close the browser session",
"browser_scroll" => "scroll the page",
"browser_wait" => "wait for an element or condition",
"browser_evaluate" => "run JavaScript on the page",
"browser_select" => "select a dropdown option",
"browser_back" => "go back to the previous page",
// Shell
"shell_exec" => "execute a shell command",
"shell_background" => "run a command in the background",
// Memory
"memory_store" => "save a key-value pair to memory",
"memory_recall" => "search memory for relevant context",
"memory_delete" => "delete a memory entry",
"memory_list" => "list stored memory keys",
// Agents
"agent_send" => "send a message to another agent",
"agent_spawn" => "create a new agent",
"agent_list" => "list running agents",
"agent_kill" => "terminate an agent",
// Media
"image_describe" => "describe an image",
"image_generate" => "generate an image from a prompt",
"audio_transcribe" => "transcribe audio to text",
"tts_speak" => "convert text to speech",
// Docker
"docker_exec" => "run a command in a container",
"docker_build" => "build a Docker image",
"docker_run" => "start a Docker container",
// Scheduling
"cron_create" => "schedule a recurring task",
"cron_list" => "list scheduled tasks",
"cron_delete" => "remove a scheduled task",
// Processes
"process_start" => "start a long-running process (REPL, server)",
"process_poll" => "read stdout/stderr from a running process",
"process_write" => "write to a process's stdin",
"process_kill" => "terminate a running process",
"process_list" => "list active processes",
_ => "",
}
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
/// Cap a string to `max_chars`, appending "..." if truncated.
fn cap_str(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let end = s
.char_indices()
.nth(max_chars)
.map(|(i, _)| i)
.unwrap_or(s.len());
format!("{}...", &s[..end])
}
}
/// Capitalize the first letter of a string.
fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn basic_ctx() -> PromptContext {
PromptContext {
agent_name: "researcher".to_string(),
agent_description: "Research agent".to_string(),
base_system_prompt: "You are Researcher, a research agent.".to_string(),
granted_tools: vec![
"web_search".to_string(),
"web_fetch".to_string(),
"file_read".to_string(),
"file_write".to_string(),
"memory_store".to_string(),
"memory_recall".to_string(),
],
..Default::default()
}
}
#[test]
fn test_full_prompt_has_all_sections() {
let prompt = build_system_prompt(&basic_ctx());
assert!(prompt.contains("You are Researcher"));
assert!(prompt.contains("## Tool Call Behavior"));
assert!(prompt.contains("## Your Tools"));
assert!(prompt.contains("## Memory"));
assert!(prompt.contains("## User Profile"));
assert!(prompt.contains("## Safety"));
assert!(prompt.contains("## Operational Guidelines"));
}
#[test]
fn test_section_ordering() {
let prompt = build_system_prompt(&basic_ctx());
let tool_behavior_pos = prompt.find("## Tool Call Behavior").unwrap();
let tools_pos = prompt.find("## Your Tools").unwrap();
let memory_pos = prompt.find("## Memory").unwrap();
let safety_pos = prompt.find("## Safety").unwrap();
let guidelines_pos = prompt.find("## Operational Guidelines").unwrap();
assert!(tool_behavior_pos < tools_pos);
assert!(tools_pos < memory_pos);
assert!(memory_pos < safety_pos);
assert!(safety_pos < guidelines_pos);
}
#[test]
fn test_subagent_omits_sections() {
let mut ctx = basic_ctx();
ctx.is_subagent = true;
let prompt = build_system_prompt(&ctx);
assert!(!prompt.contains("## Tool Call Behavior"));
assert!(!prompt.contains("## User Profile"));
assert!(!prompt.contains("## Channel"));
assert!(!prompt.contains("## Safety"));
// Subagents still get tools and guidelines
assert!(prompt.contains("## Your Tools"));
assert!(prompt.contains("## Operational Guidelines"));
assert!(prompt.contains("## Memory"));
}
#[test]
fn test_empty_tools_no_section() {
let ctx = PromptContext {
agent_name: "test".to_string(),
..Default::default()
};
let prompt = build_system_prompt(&ctx);
assert!(!prompt.contains("## Your Tools"));
}
#[test]
fn test_tool_grouping() {
let tools = vec![
"web_search".to_string(),
"web_fetch".to_string(),
"file_read".to_string(),
"browser_navigate".to_string(),
];
let section = build_tools_section(&tools);
assert!(section.contains("**Browser**"));
assert!(section.contains("**Files**"));
assert!(section.contains("**Web**"));
}
#[test]
fn test_tool_categories() {
assert_eq!(tool_category("file_read"), "Files");
assert_eq!(tool_category("web_search"), "Web");
assert_eq!(tool_category("browser_navigate"), "Browser");
assert_eq!(tool_category("shell_exec"), "Shell");
assert_eq!(tool_category("memory_store"), "Memory");
assert_eq!(tool_category("agent_send"), "Agents");
assert_eq!(tool_category("mcp_github_search"), "MCP");
assert_eq!(tool_category("unknown_tool"), "Other");
}
#[test]
fn test_tool_hints() {
assert!(!tool_hint("web_search").is_empty());
assert!(!tool_hint("file_read").is_empty());
assert!(!tool_hint("browser_navigate").is_empty());
assert!(tool_hint("some_unknown_tool").is_empty());
}
#[test]
fn test_memory_section_empty() {
let section = build_memory_section(&[]);
assert!(section.contains("## Memory"));
assert!(section.contains("memory_recall"));
assert!(!section.contains("Recalled memories"));
}
#[test]
fn test_memory_section_with_items() {
let memories = vec![
("pref".to_string(), "User likes dark mode".to_string()),
("ctx".to_string(), "Working on Rust project".to_string()),
];
let section = build_memory_section(&memories);
assert!(section.contains("Recalled memories"));
assert!(section.contains("[pref] User likes dark mode"));
assert!(section.contains("[ctx] Working on Rust project"));
}
#[test]
fn test_memory_cap_at_5() {
let memories: Vec<(String, String)> = (0..10)
.map(|i| (format!("k{i}"), format!("value {i}")))
.collect();
let section = build_memory_section(&memories);
assert!(section.contains("[k0]"));
assert!(section.contains("[k4]"));
assert!(!section.contains("[k5]"));
}
#[test]
fn test_memory_content_capped() {
let long_content = "x".repeat(1000);
let memories = vec![("k".to_string(), long_content)];
let section = build_memory_section(&memories);
// Should be capped at 500 + "..."
assert!(section.contains("..."));
assert!(section.len() < 1200);
}
#[test]
fn test_skills_section_omitted_when_empty() {
let ctx = basic_ctx();
let prompt = build_system_prompt(&ctx);
assert!(!prompt.contains("## Skills"));
}
#[test]
fn test_skills_section_present() {
let mut ctx = basic_ctx();
ctx.skill_summary = "- web-search: Search the web\n- git-expert: Git commands".to_string();
let prompt = build_system_prompt(&ctx);
assert!(prompt.contains("## Skills"));
assert!(prompt.contains("web-search"));
}
#[test]
fn test_mcp_section_omitted_when_empty() {
let ctx = basic_ctx();
let prompt = build_system_prompt(&ctx);
assert!(!prompt.contains("## Connected Tool Servers"));
}
#[test]
fn test_mcp_section_present() {
let mut ctx = basic_ctx();
ctx.mcp_summary = "- github: 5 tools (search, create_issue, ...)".to_string();
let prompt = build_system_prompt(&ctx);
assert!(prompt.contains("## Connected Tool Servers (MCP)"));
assert!(prompt.contains("github"));
}
#[test]
fn test_persona_section_with_soul() {
let mut ctx = basic_ctx();
ctx.soul_md = Some("You are a pirate. Arr!".to_string());
let prompt = build_system_prompt(&ctx);
assert!(prompt.contains("## Persona"));
assert!(prompt.contains("pirate"));
}
#[test]
fn test_persona_soul_capped_at_1000() {
let long_soul = "x".repeat(2000);
let section = build_persona_section(None, Some(&long_soul), None, None, None);
assert!(section.contains("..."));
// The raw soul content in the section should be at most 1003 chars (1000 + "...")
assert!(section.len() < 1200);
}
#[test]
fn test_channel_telegram() {
let section = build_channel_section("telegram");
assert!(section.contains("4096"));
assert!(section.contains("Telegram"));
}
#[test]
fn test_channel_discord() {
let section = build_channel_section("discord");
assert!(section.contains("2000"));
assert!(section.contains("Discord"));
}
#[test]
fn test_channel_irc() {
let section = build_channel_section("irc");
assert!(section.contains("512"));
assert!(section.contains("plain text"));
}
#[test]
fn test_channel_unknown_gets_default() {
let section = build_channel_section("smoke_signal");
assert!(section.contains("4096"));
assert!(section.contains("smoke_signal"));
}
#[test]
fn test_user_name_known() {
let mut ctx = basic_ctx();
ctx.user_name = Some("Alice".to_string());
let prompt = build_system_prompt(&ctx);
assert!(prompt.contains("Alice"));
assert!(!prompt.contains("don't know the user's name"));
}
#[test]
fn test_user_name_unknown() {
let ctx = basic_ctx();
let prompt = build_system_prompt(&ctx);
assert!(prompt.contains("don't know the user's name"));
}
#[test]
fn test_canonical_context_not_in_system_prompt() {
let mut ctx = basic_ctx();
ctx.canonical_context =
Some("User was discussing Rust async patterns last time.".to_string());
let prompt = build_system_prompt(&ctx);
// Canonical context should NOT be in system prompt (moved to user message)
assert!(!prompt.contains("## Previous Conversation Context"));
assert!(!prompt.contains("Rust async patterns"));
// But should be available via build_canonical_context_message
let msg = build_canonical_context_message(&ctx);
assert!(msg.is_some());
assert!(msg.unwrap().contains("Rust async patterns"));
}
#[test]
fn test_canonical_context_omitted_for_subagent() {
let mut ctx = basic_ctx();
ctx.is_subagent = true;
ctx.canonical_context = Some("Previous context here.".to_string());
let prompt = build_system_prompt(&ctx);
assert!(!prompt.contains("Previous Conversation Context"));
// Should also be None from build_canonical_context_message
assert!(build_canonical_context_message(&ctx).is_none());
}
#[test]
fn test_empty_base_prompt_generates_default_identity() {
let ctx = PromptContext {
agent_name: "helper".to_string(),
agent_description: "A helpful agent".to_string(),
..Default::default()
};
let prompt = build_system_prompt(&ctx);
assert!(prompt.contains("You are helper"));
assert!(prompt.contains("A helpful agent"));
}
#[test]
fn test_workspace_in_persona() {
let mut ctx = basic_ctx();
ctx.workspace_path = Some("/home/user/project".to_string());
let prompt = build_system_prompt(&ctx);
assert!(prompt.contains("## Workspace"));
assert!(prompt.contains("/home/user/project"));
}
#[test]
fn test_cap_str_short() {
assert_eq!(cap_str("hello", 10), "hello");
}
#[test]
fn test_cap_str_long() {
let result = cap_str("hello world", 5);
assert_eq!(result, "hello...");
}
#[test]
fn test_cap_str_multibyte_utf8() {
// This was panicking with "byte index is not a char boundary" (#38)
let chinese = "δ½ ε₯½δΈηθΏζ―δΈδΈͺζ΅θ―ε符串";
let result = cap_str(chinese, 4);
assert_eq!(result, "δ½ ε₯½δΈη...");
// Exact boundary
assert_eq!(cap_str(chinese, 100), chinese);
}
#[test]
fn test_cap_str_emoji() {
let emoji = "πππβ¨π―";
let result = cap_str(emoji, 3);
assert_eq!(result, "πππ...");
}
#[test]
fn test_capitalize() {
assert_eq!(capitalize("files"), "Files");
assert_eq!(capitalize(""), "");
assert_eq!(capitalize("MCP"), "MCP");
}
}