1use crate::context::{ContextFact, ContextKey};
21use std::collections::HashSet;
22use std::fmt::Write;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum PromptFormat {
27 Plain,
29 #[default]
31 Edn,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum AgentRole {
37 Proposer,
39 Validator,
41 Synthesizer,
43 Analyzer,
45}
46
47impl AgentRole {
48 fn to_keyword(self) -> &'static str {
50 match self {
51 Self::Proposer => ":proposer",
52 Self::Validator => ":validator",
53 Self::Synthesizer => ":synthesizer",
54 Self::Analyzer => ":analyzer",
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
61pub enum Constraint {
62 NoInvent,
64 NoContradict,
66 NoHallucinate,
68 CiteSources,
70}
71
72impl Constraint {
73 fn to_keyword(self) -> &'static str {
75 match self {
76 Self::NoInvent => ":no-invent",
77 Self::NoContradict => ":no-contradict",
78 Self::NoHallucinate => ":no-hallucinate",
79 Self::CiteSources => ":cite-sources",
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct OutputContract {
87 pub emit: OutputKind,
89 pub key: ContextKey,
91 pub format: Option<OutputFormat>,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum OutputKind {
98 ProposedFact,
99 Fact,
100 Analysis,
101 Evaluation,
102 Plan,
103 Classification,
104 Draft,
105 Reasoning,
106}
107
108impl OutputKind {
109 fn to_keyword(self) -> &'static str {
110 match self {
111 Self::ProposedFact => "proposed-fact",
112 Self::Fact => "fact",
113 Self::Analysis => "analysis",
114 Self::Evaluation => "evaluation",
115 Self::Plan => "plan",
116 Self::Classification => "classification",
117 Self::Draft => "draft",
118 Self::Reasoning => "reasoning",
119 }
120 }
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum OutputFormat {
126 Edn,
127 Json,
128 Xml,
129 Plain,
130}
131
132impl OutputFormat {
133 fn to_keyword(self) -> &'static str {
134 match self {
135 Self::Edn => "edn",
136 Self::Json => "json",
137 Self::Xml => "xml",
138 Self::Plain => "plain",
139 }
140 }
141}
142
143impl OutputContract {
144 #[must_use]
146 pub fn new(emit: OutputKind, key: ContextKey) -> Self {
147 Self {
148 emit,
149 key,
150 format: None,
151 }
152 }
153
154 #[must_use]
156 pub fn with_format(mut self, format: OutputFormat) -> Self {
157 self.format = Some(format);
158 self
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct AgentPrompt {
168 pub role: AgentRole,
170 pub objective: String,
172 pub context: PromptContext,
174 pub constraints: HashSet<Constraint>,
176 pub output_contract: OutputContract,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Default)]
182pub struct PromptContext {
183 pub facts: Vec<(ContextKey, Vec<ContextFact>)>,
185}
186
187impl PromptContext {
188 #[must_use]
190 pub fn new() -> Self {
191 Self::default()
192 }
193
194 pub fn add_facts(&mut self, key: ContextKey, facts: Vec<ContextFact>) {
196 if !facts.is_empty() {
197 self.facts.push((key, facts));
198 }
199 }
200
201 #[must_use]
203 pub fn from_context(ctx: &dyn crate::Context, dependencies: &[ContextKey]) -> Self {
204 let mut prompt_ctx = Self::new();
205 for &key in dependencies {
206 let facts = ctx.get(key).to_vec();
207 prompt_ctx.add_facts(key, facts);
208 }
209 prompt_ctx
210 }
211}
212
213fn context_key_to_keyword(key: ContextKey) -> &'static str {
215 match key {
216 ContextKey::Seeds => ":seeds",
217 ContextKey::Hypotheses => ":hypotheses",
218 ContextKey::Strategies => ":strategies",
219 ContextKey::Constraints => ":constraints",
220 ContextKey::Signals => ":signals",
221 ContextKey::Competitors => ":competitors",
222 ContextKey::Evaluations => ":evaluations",
223 ContextKey::Proposals => ":proposals",
224 ContextKey::Diagnostic => ":diagnostic",
225 ContextKey::Votes => ":votes",
226 ContextKey::Disagreements => ":disagreements",
227 ContextKey::ConsensusOutcomes => ":consensus_outcomes",
228 }
229}
230
231impl AgentPrompt {
232 #[must_use]
234 pub fn new(
235 role: AgentRole,
236 objective: impl Into<String>,
237 context: PromptContext,
238 output_contract: OutputContract,
239 ) -> Self {
240 Self {
241 role,
242 objective: objective.into(),
243 context,
244 constraints: HashSet::new(),
245 output_contract,
246 }
247 }
248
249 #[must_use]
251 pub fn with_constraint(mut self, constraint: Constraint) -> Self {
252 self.constraints.insert(constraint);
253 self
254 }
255
256 #[must_use]
258 pub fn with_constraints(mut self, constraints: impl IntoIterator<Item = Constraint>) -> Self {
259 self.constraints.extend(constraints);
260 self
261 }
262
263 #[must_use]
274 pub fn to_edn(&self) -> String {
275 let mut s = String::new();
276 s.push_str("{:r ");
277 s.push_str(self.role.to_keyword());
278 s.push_str("\n :o :");
279 s.push_str(&self.objective.replace(' ', "-"));
281 s.push_str("\n :c {");
282
283 let mut first_key = true;
285 for (key, facts) in &self.context.facts {
286 if !first_key {
287 s.push(' ');
288 }
289 first_key = false;
290 s.push_str(context_key_to_keyword(*key));
291 s.push_str(" [{");
292 for (i, fact) in facts.iter().enumerate() {
293 if i > 0 {
294 s.push_str("} {");
295 }
296 s.push_str(":id \"");
297 s.push_str(&escape_string(fact.id().as_str()));
298 s.push_str("\" :c \"");
299 s.push_str(&escape_string(fact.text().unwrap_or("")));
300 s.push('"');
301 }
302 s.push_str("}]");
303 }
304
305 s.push_str("}\n :k #{");
306
307 let mut constraints: Vec<_> = self.constraints.iter().collect();
309 constraints.sort(); for (i, constraint) in constraints.iter().enumerate() {
311 if i > 0 {
312 s.push(' ');
313 }
314 s.push_str(constraint.to_keyword());
315 }
316
317 s.push_str("}\n :out {:emit :");
318 s.push_str(self.output_contract.emit.to_keyword());
319 s.push_str(" :key ");
320 s.push_str(context_key_to_keyword(self.output_contract.key));
321 if let Some(format) = self.output_contract.format {
322 s.push_str(" :format :");
323 s.push_str(format.to_keyword());
324 }
325 s.push_str("}}");
326
327 s
328 }
329
330 #[must_use]
332 pub fn to_plain(&self) -> String {
333 let mut s = String::new();
334 writeln!(s, "Role: {:?}", self.role).unwrap();
335 writeln!(s, "Objective: {}", self.objective).unwrap();
336 writeln!(s, "\nContext:").unwrap();
337
338 for (key, facts) in &self.context.facts {
339 writeln!(s, "\n## {key:?}").unwrap();
340 for fact in facts {
341 writeln!(s, "- {}: {}", fact.id(), fact.text().unwrap_or("")).unwrap();
342 }
343 }
344
345 if !self.constraints.is_empty() {
346 writeln!(s, "\nConstraints:").unwrap();
347 for constraint in &self.constraints {
348 writeln!(s, "- {constraint:?}").unwrap();
349 }
350 }
351
352 writeln!(
353 s,
354 "\nOutput: {:?} -> {:?}",
355 self.output_contract.emit, self.output_contract.key
356 )
357 .unwrap();
358
359 s
360 }
361
362 #[must_use]
364 pub fn serialize(&self, format: PromptFormat) -> String {
365 match format {
366 PromptFormat::Edn => self.to_edn(),
367 PromptFormat::Plain => self.to_plain(),
368 }
369 }
370}
371
372pub type DslOutputContract = OutputContract;
375
376fn escape_string(s: &str) -> String {
377 s.replace('\\', "\\\\")
378 .replace('"', "\\\"")
379 .replace('\n', "\\n")
380 .replace('\r', "\\r")
381 .replace('\t', "\\t")
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::context::ContextState;
388
389 #[test]
390 fn test_edn_serialization() {
391 let mut ctx = PromptContext::new();
392 ctx.add_facts(
393 ContextKey::Signals,
394 vec![
395 crate::context::new_fact(ContextKey::Signals, "s1", "Revenue +15% Q3"),
396 crate::context::new_fact(ContextKey::Signals, "s2", "Market $2.3B"),
397 ],
398 );
399
400 let prompt = AgentPrompt::new(
401 AgentRole::Proposer,
402 "extract-competitors",
403 ctx,
404 OutputContract::new(OutputKind::ProposedFact, ContextKey::Competitors),
405 )
406 .with_constraint(Constraint::NoInvent)
407 .with_constraint(Constraint::NoContradict);
408
409 let edn = prompt.to_edn();
410 assert!(edn.contains(":r :proposer"));
411 assert!(edn.contains(":o :extract-competitors"));
412 assert!(edn.contains(":signals"));
413 assert!(edn.contains(":no-invent"));
414 assert!(edn.contains(":no-contradict"));
415 assert!(edn.contains(":competitors"));
416 }
417
418 #[test]
419 fn test_context_building() {
420 let mut context = ContextState::new();
421 context
422 .add_fact(crate::context::new_fact(
423 ContextKey::Seeds,
424 "seed1",
425 "Test seed",
426 ))
427 .unwrap();
428
429 let prompt_ctx = PromptContext::from_context(&context, &[ContextKey::Seeds]);
430 assert_eq!(prompt_ctx.facts.len(), 1);
431 assert_eq!(prompt_ctx.facts[0].0, ContextKey::Seeds);
432 assert_eq!(prompt_ctx.facts[0].1.len(), 1);
433 }
434
435 #[test]
436 fn test_escape_string() {
437 assert_eq!(escape_string("hello"), "hello");
438 assert_eq!(escape_string("hello\"world"), "hello\\\"world");
439 assert_eq!(escape_string("hello\nworld"), "hello\\nworld");
440 }
441
442 #[test]
443 fn test_token_efficiency() {
444 let mut ctx = PromptContext::new();
445 ctx.add_facts(
446 ContextKey::Signals,
447 vec![crate::context::new_fact(
448 ContextKey::Signals,
449 "s1",
450 "Revenue +15% Q3",
451 )],
452 );
453
454 let prompt = AgentPrompt::new(
455 AgentRole::Proposer,
456 "analyze",
457 ctx,
458 OutputContract::new(OutputKind::ProposedFact, ContextKey::Strategies),
459 );
460
461 let edn = prompt.to_edn();
462 let plain = prompt.to_plain();
463
464 println!("EDN length: {}", edn.len());
465 println!("Plain length: {}", plain.len());
466 println!("EDN:\n{edn}");
467 println!("Plain:\n{plain}");
468
469 assert!(!edn.is_empty());
474 assert!(!plain.is_empty());
475 }
476}