1use std::collections::{BTreeMap, BTreeSet};
4use std::sync::Arc;
5
6use gemini_adk_rs::flow::{Enforcement, Flow};
7use gemini_adk_rs::state::State;
8use gemini_adk_rs::tasks::{
9 CompiledSkill, TaskEffect, TaskEffects, TaskPattern, TaskServices, TaskServicesFactory,
10 TaskTool, TaskToolEffect, TaskWatcher,
11};
12use gemini_adk_rs::tool::{ContextTool, ToolContext, ToolDispatcher, ToolFunction};
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15
16use super::{SessionSpec, SpecResources, SpecTest, StateFieldSpec, StateType, ToolSpec};
17
18#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
20pub struct SkillToolSpec {
21 #[serde(flatten)]
23 pub tool: ToolSpec,
24 pub effect: TaskToolEffect,
26}
27
28#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
30pub struct SkillSpec {
31 pub name: String,
33 pub version: String,
35 #[serde(default)]
37 pub description: String,
38 #[serde(default)]
40 pub instruction: String,
41 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
43 pub inputs: BTreeMap<String, StateFieldSpec>,
44 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
46 pub outputs: BTreeMap<String, StateFieldSpec>,
47 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
49 pub state: BTreeMap<String, StateFieldSpec>,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub tools: Vec<SkillToolSpec>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub extract: Vec<super::ExtractSpec>,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub computed: Vec<super::ComputedSpec>,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub watch: Vec<super::WatchSpec>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub patterns: Vec<super::PatternSpec>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub memory: Option<super::MemorySpec>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub flow: Option<Flow>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub conversation: Option<crate::conversation::ConversationSpec>,
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub tests: Vec<SpecTest>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub code: Option<super::CodeSpec>,
83}
84
85impl SkillSpec {
86 pub(super) fn definition(&self) -> SessionSpec {
87 let mut state = self.state.clone();
88 state.extend(self.inputs.clone());
89 for (name, field) in &self.outputs {
90 state.entry(name.clone()).or_insert_with(|| field.clone());
91 }
92 SessionSpec {
93 name: self.name.clone(),
94 version: self.version.clone(),
95 description: self.description.clone(),
96 instruction: self.instruction.clone(),
97 state,
98 tools: self.tools.iter().map(|t| t.tool.clone()).collect(),
99 extract: self.extract.clone(),
100 computed: self.computed.clone(),
101 watch: self.watch.clone(),
102 patterns: self.patterns.clone(),
103 memory: self.memory.clone(),
104 flow: self.flow.clone(),
105 conversation: self.conversation.clone(),
106 tests: self.tests.clone(),
107 ..Default::default()
108 }
109 .with_skill_code(self)
110 }
111
112 pub fn validate(&self) -> Result<(), Vec<String>> {
114 self.validate_with_http_support(true)
115 }
116
117 pub(super) fn validate_with_http_support(&self, http_support: bool) -> Result<(), Vec<String>> {
118 let validation = self.validation(http_support);
119 if validation.valid {
120 Ok(())
121 } else {
122 Err(validation.errors)
123 }
124 }
125
126 pub(super) fn validation(&self, http_support: bool) -> super::SpecValidation {
127 let mut errors = Vec::new();
128 if let Err(error) = gemini_adk_rs::tasks::validate_skill_identity(&self.name, &self.version)
129 {
130 errors.push(error.to_string());
131 }
132 let mut names = BTreeSet::new();
133 for tool in &self.tools {
134 if let Err(error) = task_parameters(tool) {
135 errors.push(error);
136 }
137 if tool.tool.background || tool.tool.scheduling.is_some() {
138 errors.push(format!(
139 "skill tool '{}' uses task-owned asynchronous delivery; background and scheduling overrides are unsupported",
140 tool.tool.name
141 ));
142 }
143 if tool.tool.name.trim().is_empty() || !names.insert(tool.tool.name.as_str()) {
144 errors.push(format!(
145 "skill '{}' has an empty or duplicate tool name '{}'",
146 self.name, tool.tool.name
147 ));
148 }
149 if let Err(error) = (TaskTool {
150 name: tool.tool.name.clone(),
151 description: tool.tool.description.clone(),
152 parameters: tool.tool.parameters.clone(),
153 effect: tool.effect.clone(),
154 })
155 .validate_for_skill(&self.name)
156 {
157 errors.push(error.to_string());
158 }
159 }
160 let mut writers = self.definition();
163 writers.computed.clear();
164 for key in writers
165 .state_keys_written()
166 .into_iter()
167 .chain(self.computed.iter().map(|value| value.key.clone()))
168 {
169 if key.trim().is_empty()
170 || ["task:", "flow:", "derived:"]
171 .iter()
172 .any(|prefix| key.starts_with(prefix))
173 {
174 errors.push(format!(
175 "skill '{}' writes empty or runtime-owned state key '{key}'",
176 self.name
177 ));
178 }
179 }
180 let mut extractor_names = BTreeSet::new();
181 for extractor in &self.extract {
182 if extractor.name.trim().is_empty() || !extractor_names.insert(&extractor.name) {
183 errors.push(format!(
184 "skill '{}' has an empty or duplicate extractor name '{}'",
185 self.name, extractor.name
186 ));
187 }
188 if extractor.window == 0 {
189 errors.push(format!(
190 "task extractor '{}' needs a nonzero transcript window",
191 extractor.name
192 ));
193 }
194 if extractor.trigger == super::TriggerSpec::OnPhaseChange {
195 errors.push(format!("task extractor '{}' cannot use on_phase_change: task skills do not have phases", extractor.name));
196 }
197 }
198 if self.memory.is_some() {
199 for name in super::MEMORY_TOOL_NAMES {
200 if names.contains(name) {
201 errors.push(format!(
202 "task memory owns tool '{name}'; do not redeclare it"
203 ));
204 }
205 }
206 }
207 for (scope, fields) in [
208 ("input", &self.inputs),
209 ("output", &self.outputs),
210 ("state", &self.state),
211 ] {
212 for (name, field) in fields {
213 if name.trim().is_empty()
214 || name.starts_with("flow:")
215 || name.starts_with("task:")
216 || name.starts_with("derived:")
217 {
218 errors.push(format!(
219 "{scope} field '{name}' uses an empty or runtime-owned name"
220 ));
221 }
222 if scope != "state" && field.kind.is_none() {
223 errors.push(format!("{scope} field '{name}' needs a declared type"));
224 }
225 if let (Some(kind), Some(default)) = (field.kind, &field.default)
226 && !kind.matches(default)
227 {
228 errors.push(format!(
229 "{scope} field '{name}' has a default of the wrong type"
230 ));
231 }
232 for other in [&self.state, &self.inputs, &self.outputs] {
233 if let Some(other) = other.get(name) {
234 if other.kind != field.kind {
235 errors.push(format!(
236 "field '{name}' has conflicting types across contracts"
237 ));
238 }
239 if matches!((&other.default, &field.default), (Some(a), Some(b)) if a != b)
240 {
241 errors.push(format!(
242 "field '{name}' has conflicting defaults across contracts"
243 ));
244 }
245 }
246 }
247 }
248 }
249 if let Some(conversation) = &self.conversation {
250 for stage in conversation.stages.iter().chain(
251 conversation
252 .overlays
253 .iter()
254 .flat_map(|overlay| overlay.stages.iter()),
255 ) {
256 if stage.frame.is_some() || !stage.resolve.is_empty() {
257 errors.push(format!("stage '{}' requires stage frame extraction or resolvers, which task skills do not yet support; use skill-level extract for owned speech fields", stage.id));
258 }
259 if stage.verbatim.is_some() || stage.timing.is_some() {
260 errors.push(format!("stage '{}' requires task-owned transcript or voice timing projection, which task skills do not yet support", stage.id));
261 }
262 }
263 for policy in &conversation.policies {
264 match policy {
265 crate::policy::Policy::Redact { .. } => {
266 errors.push("task skills do not yet provide redaction across input steering, approvals and results; redact policies are unsupported".into());
267 }
268 crate::policy::Policy::Commit { tool, .. }
269 if !self.tools.iter().any(|t| {
270 t.tool.name == *tool
271 && matches!(t.effect, TaskToolEffect::Commit { .. })
272 }) =>
273 {
274 errors.push(format!(
275 "commit policy '{tool}' needs a commit effect declaration"
276 ));
277 }
278 _ => {}
279 }
280 }
281 }
282 if let Some(code) = &self.code {
283 for name in &code.functions {
284 if self.tools.iter().any(|t| {
285 &t.tool.name == name && matches!(t.effect, TaskToolEffect::Commit { .. })
286 }) {
287 errors.push(format!(
288 "skill '{}' code: '{name}' is a commit, which waits for approval in \
289 the application; a program cannot wait for it, so leave it out of \
290 code.functions",
291 self.name
292 ));
293 }
294 }
295 }
296 if !self.tests.is_empty() && self.flow.is_none() && self.conversation.is_none() {
297 errors.push("skill tests require a governed flow or conversation; use task_scenarios for lifecycle checks".into());
298 }
299 let mut validation = self
300 .definition()
301 .validate_definition(http_support, &self.inputs.keys().cloned().collect());
302 errors.extend(validation.errors);
303 validation.valid = errors.is_empty();
304 validation.errors = errors;
305 if self.memory.is_some() {
306 for name in super::MEMORY_TOOL_NAMES {
307 if !validation.tools.iter().any(|tool| tool == name) {
308 validation.tools.push(name.into());
309 }
310 }
311 }
312 validation
313 }
314
315 pub fn run_tests(&self) -> Vec<super::TestReport> {
318 self.definition().run_tests()
319 }
320
321 pub fn compile(&self, resources: &SpecResources) -> Result<CompiledSkill, String> {
323 self.compile_mode(resources, false, &super::ModelsSpec::default())
324 }
325
326 pub(super) fn compile_mode(
329 &self,
330 resources: &SpecResources,
331 offline: bool,
332 models: &super::ModelsSpec,
333 ) -> Result<CompiledSkill, String> {
334 self.validate_with_http_support(!offline)
335 .map_err(|e| e.join("; "))?;
336 let definition = self.definition();
337 let services = self.compile_services(resources, offline, models)?;
338 let memory_enabled = self.memory.is_some();
339 let flow_factory: Option<gemini_adk_rs::tasks::FlowFactory> =
340 if let Some(conversation) = &self.conversation {
341 let compiled = crate::conversation::Conversation::from_spec_stubbing_resolvers(
342 conversation.clone(),
343 )
344 .map_err(|e| e.to_string())?;
345 Some(Arc::new(move || {
346 let stack = compiled.stack(Enforcement::Enforce);
347 Ok(if memory_enabled {
348 stack.with_ambient_tools(super::MEMORY_TOOL_NAMES.map(str::to_owned))
349 } else {
350 stack
351 })
352 }))
353 } else if let Some(flow) = &self.flow {
354 let mut flow = flow.clone();
355 if memory_enabled {
356 flow.ambient
357 .extend(super::MEMORY_TOOL_NAMES.map(str::to_owned));
358 }
359 let compiled = flow.compile().map_err(|e| e.to_string())?;
360 Some(Arc::new(move || {
361 Ok(gemini_adk_rs::flow::FlowStack::new(
362 compiled.clone(),
363 Enforcement::Enforce,
364 ))
365 }))
366 } else {
367 None
368 };
369 let mut initial_state = std::collections::HashMap::new();
370 for (key, field) in &definition.state {
371 if let Some(value) = &field.default {
372 initial_state.insert(key.clone(), value.clone());
373 }
374 }
375 let input_fields = self.inputs.clone();
376 let output_fields = self.outputs.clone();
377 let projected_fields = self.outputs.clone();
378 let receipt_tools: BTreeMap<_, _> = self
379 .tools
380 .iter()
381 .map(|declaration| (declaration.tool.name.clone(), declaration.tool.clone()))
382 .collect();
383 let mut skill = self.clone();
384 let memory_tools = if self.memory.is_some() && !offline {
385 resources
386 .memory
387 .as_ref()
388 .ok_or("task memory requires SpecResources.memory")?
389 .task_tools()
390 } else {
391 Vec::new()
392 };
393 if self.memory.is_some() {
394 for name in super::MEMORY_TOOL_NAMES {
395 let implementation = memory_tools.iter().find(|tool| tool.name() == name);
396 if !offline && implementation.is_none() {
397 return Err(format!("task memory binding does not implement '{name}'"));
398 }
399 let effect = if name == "manage_memory" {
400 TaskToolEffect::Commit {
401 idempotency_argument: "request_id".into(),
402 }
403 } else {
404 TaskToolEffect::Read
405 };
406 skill.tools.push(SkillToolSpec {
407 tool: ToolSpec {
408 name: name.into(),
409 description: implementation.map_or_else(
410 || format!("Controlled memory {name}"),
411 |tool| tool.description().to_owned(),
412 ),
413 parameters: implementation
414 .and_then(gemini_adk_rs::ToolFunction::parameters),
415 response: Some(json!({"fixture":true})),
416 returns: None,
417 set_state: BTreeMap::new(),
418 save_response_as: None,
419 http: None,
420 mcp: None,
421 background: false,
422 scheduling: None,
423 },
424 effect,
425 });
426 }
427 }
428 for declaration in &mut skill.tools {
429 declaration.tool.parameters = task_parameters(declaration)?;
430 }
431 let tools = skill
432 .tools
433 .iter()
434 .map(|t| TaskTool {
435 name: t.tool.name.clone(),
436 description: t.tool.description.clone(),
437 parameters: t.tool.parameters.clone(),
438 effect: t.effect.clone(),
439 })
440 .collect();
441 let mut code: BTreeMap<String, Arc<dyn ToolFunction>> = if offline {
442 BTreeMap::new()
443 } else {
444 self.tools
445 .iter()
446 .filter_map(|tool| {
447 resources
448 .tools
449 .get(&format!("{}/{}", self.name, tool.tool.name))
450 .or_else(|| resources.tools.get(&tool.tool.name))
451 .map(|implementation| (tool.tool.name.clone(), implementation.clone()))
452 })
453 .collect()
454 };
455 for tool in memory_tools {
456 code.insert(tool.name().to_owned(), tool);
457 }
458 Ok(CompiledSkill {
459 name: self.name.clone(),
460 version: self.version.clone(),
461 description: self.description.clone(),
462 instruction: self.instruction.clone(),
463 input_schema: field_schema(&self.inputs),
464 output_schema: field_schema(&self.outputs),
465 validate_input: Arc::new(move |value| validate_fields(&input_fields, value)),
466 validate_output: Arc::new(move |value| validate_fields(&output_fields, value)),
467 initial_state,
468 flow_factory,
469 services,
470 tool_factory: Arc::new(move |state| skill.bind_tools(state, &code, offline)),
471 tools,
472 project_output: Some(Arc::new(move |state| {
473 let value = Value::Object(
474 projected_fields
475 .keys()
476 .filter_map(|key| state.get_raw(key).map(|value| (key.clone(), value)))
477 .collect(),
478 );
479 validate_fields(&projected_fields, &value)?;
480 Ok(value)
481 })),
482 receipt_applier: Some(Arc::new(move |name, result, state| {
483 if memory_enabled && super::MEMORY_TOOL_NAMES.contains(&name) {
484 return Ok(());
485 }
486 let declaration = receipt_tools
487 .get(name)
488 .ok_or_else(|| format!("unknown receipt tool '{name}'"))?;
489 apply_result_state(declaration, result, state)
490 })),
491 code: definition.compile_code_mode(resources.code_sandbox.clone()),
492 })
493 }
494
495 fn compile_services(
496 &self,
497 resources: &SpecResources,
498 offline: bool,
499 models: &super::ModelsSpec,
500 ) -> Result<Option<TaskServicesFactory>, String> {
501 if self.extract.is_empty()
502 && self.computed.is_empty()
503 && self.watch.is_empty()
504 && self.patterns.is_empty()
505 && self.memory.is_none()
506 {
507 return Ok(None);
508 }
509 let llms: Vec<Arc<dyn gemini_adk_rs::llm::BaseLlm>> = if offline {
511 let mock: Arc<dyn gemini_adk_rs::llm::BaseLlm> =
514 Arc::new(gemini_adk_rs::llm::MockLlm::script([]));
515 self.extract.iter().map(|_| mock.clone()).collect()
516 } else {
517 let mut cache = std::collections::BTreeMap::new();
518 self.extract
519 .iter()
520 .map(|e| {
521 super::extraction_llm_for(
522 e,
523 models.extraction.as_deref(),
524 resources,
525 &mut cache,
526 )
527 .map_err(|err| format!("task {err}"))
528 })
529 .collect::<Result<_, _>>()?
530 };
531 let memory = self
532 .memory
533 .as_ref()
534 .map(|memory| {
535 let binding = resources
536 .memory
537 .as_ref()
538 .ok_or("task memory requires SpecResources.memory")?;
539 let provider = binding.task_memory(memory)?;
540 let fields = self.definition().state;
541 Ok::<_, String>(Arc::new(ValidatedTaskMemory {
542 provider,
543 slots: memory.slots.clone(),
544 fields,
545 })
546 as Arc<dyn gemini_adk_rs::tasks::TaskMemoryService>)
547 })
548 .transpose()?;
549 let skill = self.clone();
550 let models = models.clone();
551 let factory: TaskServicesFactory = Arc::new(move || {
552 let mut services = TaskServices {
553 memory: memory.clone(),
554 ..Default::default()
555 };
556 services.extractors = skill
557 .extract
558 .iter()
559 .zip(&llms)
560 .map(|(e, llm)| {
561 Arc::new(super::compile_extractor(e, llm.clone(), &models))
562 as Arc<dyn gemini_adk_rs::live::extractor::TurnExtractor>
563 })
564 .collect();
565 for spec in &skill.computed {
566 let expression = spec.from.clone();
567 services
568 .computed
569 .register(gemini_adk_rs::live::computed::ComputedVar {
570 key: spec.key.clone(),
571 dependencies: expression.keys_read().into_iter().collect(),
572 compute: Arc::new(move |state| expression.eval(state)),
573 })
574 .map_err(|error| error.to_string())?;
575 }
576 services.watchers = skill
577 .watch
578 .iter()
579 .map(|watch| TaskWatcher {
580 key: watch.key.clone(),
581 predicate: match &watch.condition {
582 super::WatchCondition::Changed => {
583 gemini_adk_rs::live::watcher::WatchPredicate::Changed
584 }
585 super::WatchCondition::ChangedTo(v) => {
586 gemini_adk_rs::live::watcher::WatchPredicate::ChangedTo(v.clone())
587 }
588 super::WatchCondition::CrossedAbove(v) => {
589 gemini_adk_rs::live::watcher::WatchPredicate::CrossedAbove(*v)
590 }
591 super::WatchCondition::CrossedBelow(v) => {
592 gemini_adk_rs::live::watcher::WatchPredicate::CrossedBelow(*v)
593 }
594 super::WatchCondition::BecameTrue => {
595 gemini_adk_rs::live::watcher::WatchPredicate::BecameTrue
596 }
597 super::WatchCondition::BecameFalse => {
598 gemini_adk_rs::live::watcher::WatchPredicate::BecameFalse
599 }
600 },
601 effects: task_effects(&watch.set, &watch.effects),
602 })
603 .collect();
604 for pattern in &skill.patterns {
605 let guard = pattern.when.clone();
606 let condition = Arc::new(move |state: &State| guard.eval_state(state));
607 let detector: Box<dyn gemini_adk_rs::live::temporal::PatternDetector> =
608 match (pattern.sustained_secs, pattern.turns) {
609 (Some(seconds), None) => {
610 Box::new(gemini_adk_rs::live::temporal::SustainedDetector::new(
611 condition,
612 std::time::Duration::from_secs(seconds),
613 ))
614 }
615 (None, Some(turns)) => Box::new(
616 gemini_adk_rs::live::temporal::TurnCountDetector::new(condition, turns),
617 ),
618 _ => {
619 return Err(format!(
620 "pattern '{}' requires exactly one duration or turn count",
621 pattern.name
622 ));
623 }
624 };
625 services.patterns.push(TaskPattern {
626 name: pattern.name.clone(),
627 detector,
628 cooldown: None,
629 effects: task_effects(&BTreeMap::new(), &pattern.effects),
630 });
631 }
632 Ok(services)
633 });
634 factory()?;
636 Ok(Some(factory))
637 }
638
639 fn bind_tools(
640 &self,
641 state: State,
642 code: &BTreeMap<String, Arc<dyn ToolFunction>>,
643 offline: bool,
644 ) -> Result<ToolDispatcher, String> {
645 let mut dispatcher = ToolDispatcher::new();
646 for declaration in &self.tools {
647 let tool = declaration.tool.clone();
648 let implementation = code.get(&tool.name).cloned();
649 let server = if offline {
650 None
651 } else {
652 tool.mcp.as_ref().map(|params| {
653 Arc::new(gemini_adk_rs::tools::mcp::McpSessionManager::new(
654 crate::live::connect::parse_mcp_params(params),
655 ))
656 })
657 };
658 let snapshot = state.clone();
659 dispatcher.register(ContextTool::new(
660 tool.name.clone(),
661 tool.description.clone(),
662 tool.parameters.clone(),
663 move |args, mut ctx: ToolContext| {
664 let tool = tool.clone();
665 let implementation = implementation.clone();
666 let server = server.clone();
667 let snapshot = snapshot.clone();
668 async move {
669 ctx.state = snapshot.clone();
670 let result = if let Some(implementation) = implementation {
671 implementation.call_with_context(args, ctx).await?
672 } else if let Some(server) = server {
673 super::mcp_value(
674 server
675 .call_tool(&tool.name, args)
676 .await
677 .map_err(|e| gemini_adk_rs::ToolError::Other(e.to_string()))?,
678 )
679 } else if let Some(binding) = tool.http.as_ref().filter(|_| !offline) {
680 super::execute_http(binding, &args, &snapshot)
681 .await
682 .map_err(gemini_adk_rs::ToolError::Other)?
683 } else {
684 tool.response.clone().unwrap_or_else(|| json!({"ok":true}))
685 };
686 apply_result_state(&tool, &result, &snapshot)
687 .map_err(gemini_adk_rs::ToolError::Other)?;
688 Ok(result)
689 }
690 },
691 ));
692 }
693 if let Some(conversation) = &self.conversation {
694 let mut dispatcher = Some(dispatcher);
695 crate::live::connect::apply_policies(&conversation.policies, &state, &mut dispatcher)
696 .map_err(|e| e.to_string())?;
697 Ok(dispatcher.expect("policy application retains dispatcher"))
698 } else {
699 Ok(dispatcher)
700 }
701 }
702
703 pub(super) fn sandboxed(&self, allow: &super::BindingAllowlist) -> (Self, Vec<String>) {
704 let (definition, notes) = self.definition().sandboxed(allow);
705 let mut skill = self.clone();
706 for (target, source) in skill.tools.iter_mut().zip(definition.tools) {
707 target.tool = source;
708 }
709 (
710 skill,
711 notes
712 .into_iter()
713 .map(|note| format!("skill '{}': {note}", self.name))
714 .collect(),
715 )
716 }
717}
718
719fn apply_result_state(tool: &ToolSpec, result: &Value, state: &State) -> Result<(), String> {
722 for (key, value) in &tool.set_state {
723 state.set(key, value.clone()).map_err(|e| e.to_string())?;
724 }
725 if let Some(key) = &tool.save_response_as {
726 state.set(key, result.clone()).map_err(|e| e.to_string())?;
727 }
728 Ok(())
729}
730
731pub(super) fn task_parameters(declaration: &SkillToolSpec) -> Result<Option<Value>, String> {
732 let TaskToolEffect::Commit {
733 idempotency_argument,
734 } = &declaration.effect
735 else {
736 return Ok(declaration.tool.parameters.clone());
737 };
738 let invalid = || {
739 format!(
740 "commit tool '{}' needs object parameters with string idempotency field '{idempotency_argument}'",
741 declaration.tool.name
742 )
743 };
744 let mut parameters = declaration
745 .tool
746 .parameters
747 .clone()
748 .unwrap_or_else(|| json!({}));
749 let object = parameters.as_object_mut().ok_or_else(invalid)?;
750 if object.get("type").is_some_and(|kind| kind != "object") {
751 return Err(invalid());
752 }
753 object.insert("type".into(), json!("object"));
754 let properties = object
755 .entry("properties")
756 .or_insert_with(|| json!({}))
757 .as_object_mut()
758 .ok_or_else(invalid)?;
759 let field = properties
760 .entry(idempotency_argument.clone())
761 .or_insert_with(|| json!({}))
762 .as_object_mut()
763 .ok_or_else(invalid)?;
764 if field.get("type").is_some_and(|kind| kind != "string") {
765 return Err(invalid());
766 }
767 field.insert("type".into(), json!("string"));
768 field
769 .entry("description")
770 .or_insert_with(|| json!("Nonempty external idempotency key for this exact operation."));
771 let required = object
772 .entry("required")
773 .or_insert_with(|| json!([]))
774 .as_array_mut()
775 .ok_or_else(invalid)?;
776 if !required.iter().all(Value::is_string) {
777 return Err(invalid());
778 }
779 if !required.iter().any(|key| key == idempotency_argument) {
780 required.push(json!(idempotency_argument));
781 }
782 Ok(Some(parameters))
783}
784
785fn field_schema(fields: &BTreeMap<String, StateFieldSpec>) -> Value {
786 let properties: serde_json::Map<String, Value> = fields
787 .iter()
788 .map(|(name, field)| {
789 let mut schema = json!({"description": field.description});
790 if let Some(kind) = field.kind {
791 schema["type"] = json!(match kind {
792 StateType::Boolean => "boolean",
793 StateType::Number => "number",
794 StateType::String => "string",
795 StateType::Object => "object",
796 StateType::Array => "array",
797 });
798 }
799 if let Some(default) = &field.default {
800 schema["default"] = default.clone();
801 }
802 (name.clone(), schema)
803 })
804 .collect();
805 json!({"type":"object", "properties":properties, "required":fields.iter().filter(|(_, f)| f.default.is_none()).map(|(k, _)| k).collect::<Vec<_>>(), "additionalProperties":false})
806}
807
808fn validate_fields(fields: &BTreeMap<String, StateFieldSpec>, value: &Value) -> Result<(), String> {
809 let object = value.as_object().ok_or("contract expects an object")?;
810 for key in object.keys() {
811 if !fields.contains_key(key) {
812 return Err(format!("unknown contract field '{key}'"));
813 }
814 }
815 for (key, field) in fields {
816 match object.get(key) {
817 Some(value) if field.kind.is_some_and(|kind| !kind.matches(value)) => {
818 return Err(format!("field '{key}' has the wrong type"));
819 }
820 None if field.default.is_none() => {
821 return Err(format!("required field '{key}' is missing"));
822 }
823 _ => {}
824 }
825 }
826 Ok(())
827}
828
829fn task_effects(sets: &BTreeMap<String, Value>, effects: &[super::EffectSpec]) -> TaskEffects {
830 let sets = sets.clone();
831 let effects = effects.to_vec();
832 Arc::new(move |state| {
833 let mut out: Vec<_> = sets
834 .iter()
835 .map(|(key, value)| TaskEffect::Set {
836 key: key.clone(),
837 value: value.clone(),
838 })
839 .collect();
840 for effect in &effects {
841 match effect {
842 super::EffectSpec::Set(values) => {
843 out.extend(values.iter().map(|(key, value)| TaskEffect::Set {
844 key: key.clone(),
845 value: value.clone(),
846 }));
847 }
848 super::EffectSpec::Context(text) => out.push(TaskEffect::Context(text.clone())),
849 super::EffectSpec::Prompt(text) => out.push(TaskEffect::Prompt(text.clone())),
850 super::EffectSpec::Remember(template) => out.push(TaskEffect::Remember(
851 super::interpolate(template, &Value::Null, state),
852 )),
853 }
854 }
855 out
856 })
857}
858
859struct ValidatedTaskMemory {
860 provider: Arc<dyn gemini_adk_rs::tasks::TaskMemoryService>,
861 slots: Vec<super::MemorySlotSpec>,
862 fields: BTreeMap<String, StateFieldSpec>,
863}
864impl ValidatedTaskMemory {
865 fn validate_projection(&self, value: Value) -> Result<Value, String> {
866 let object = value
867 .as_object()
868 .ok_or("task memory projection must be an object")?;
869 for (key, value) in object {
870 if !self.slots.iter().any(|slot| slot.to == *key) {
871 return Err(format!("task memory projected undeclared slot '{key}'"));
872 }
873 if !value.is_null()
874 && self
875 .fields
876 .get(key)
877 .and_then(|field| field.kind)
878 .is_some_and(|kind| !kind.matches(value))
879 {
880 return Err(format!("task memory slot '{key}' has the wrong type"));
881 }
882 }
883 Ok(value)
884 }
885}
886#[async_trait::async_trait]
887impl gemini_adk_rs::tasks::TaskMemoryService for ValidatedTaskMemory {
888 async fn project(&self) -> Result<Value, String> {
889 self.validate_projection(self.provider.project().await?)
890 }
891 async fn ingest(&self, id: String, turn: u64, user: String) -> Result<Value, String> {
892 self.validate_projection(self.provider.ingest(id, turn, user).await?)
893 }
894 async fn remember(&self, id: String, turn: u64, note: String) -> Result<Value, String> {
895 self.validate_projection(self.provider.remember(id, turn, note).await?)
896 }
897}
898
899#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
901pub struct TaskScenario {
902 pub name: String,
904 pub steps: Vec<TaskStep>,
906}
907
908#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
910#[serde(tag = "event", rename_all = "snake_case")]
911pub enum TaskStep {
912 Command {
914 command: gemini_adk_rs::tasks::TaskCommand,
916 #[serde(default)]
918 defer: bool,
919 #[serde(default, skip_serializing_if = "Option::is_none")]
921 expected_error: Option<String>,
922 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
924 extraction: BTreeMap<String, Value>,
925 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
927 extraction_errors: BTreeMap<String, String>,
928 },
929 Turn,
931 Finish {
933 operation: gemini_adk_rs::tasks::OperationId,
935 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
937 extraction: BTreeMap<String, Value>,
938 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
940 extraction_errors: BTreeMap<String, String>,
941 },
942 Observe {
944 task: gemini_adk_rs::tasks::TaskId,
946 #[serde(default, skip_serializing_if = "Option::is_none")]
948 revision: Option<u64>,
949 #[serde(default)]
951 trigger: TaskObservationKind,
952 #[serde(default)]
954 user: String,
955 #[serde(default)]
957 model: String,
958 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
960 extraction: BTreeMap<String, Value>,
961 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
963 extraction_errors: BTreeMap<String, String>,
964 #[serde(default)]
966 defer: bool,
967 },
968 FinishService {
970 operation: gemini_adk_rs::tasks::OperationId,
972 },
973 AdvanceTime {
975 millis: u64,
977 },
978 Memory {
980 skill: String,
982 values: BTreeMap<String, Value>,
984 },
985 Expect {
987 #[serde(default, skip_serializing_if = "Option::is_none")]
989 foreground: Option<gemini_adk_rs::tasks::TaskId>,
990 #[serde(default, skip_serializing_if = "Vec::is_empty")]
992 tasks: Vec<TaskExpectation>,
993 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
995 operations: BTreeMap<String, gemini_adk_rs::tasks::OperationStatus>,
996 },
997}
998
999#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)]
1001#[serde(rename_all = "snake_case")]
1002pub enum TaskObservationKind {
1003 #[default]
1005 Turn,
1006 GenerationComplete,
1008 Interrupted,
1010 Timer,
1012}
1013
1014#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
1016pub struct TaskExpectation {
1017 pub task: String,
1019 #[serde(default, skip_serializing_if = "Option::is_none")]
1021 pub status: Option<gemini_adk_rs::tasks::TaskStatus>,
1022 #[serde(default, skip_serializing_if = "Option::is_none")]
1024 pub version: Option<String>,
1025 #[serde(default, skip_serializing_if = "Option::is_none")]
1027 pub revision: Option<u64>,
1028 #[serde(default, skip_serializing_if = "Option::is_none")]
1030 pub output: Option<Value>,
1031 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1033 pub active: Vec<String>,
1034 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1036 pub done: Vec<String>,
1037}
1038
1039#[derive(Debug, Clone, Serialize)]
1041pub struct TaskTraceSnapshot {
1042 pub index: usize,
1044 pub event: String,
1046 pub failures: Vec<String>,
1048 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1050 pub service_effects: Vec<gemini_adk_rs::tasks::AcceptedTaskEffect>,
1051 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1053 pub deferred_services: Vec<gemini_adk_rs::tasks::TaskOwner>,
1054 #[serde(flatten)]
1056 pub status: gemini_adk_rs::tasks::TaskSessionSnapshot,
1057}
1058
1059impl SessionSpec {
1060 pub fn compile_tasks(
1062 &self,
1063 resources: &SpecResources,
1064 ) -> Result<gemini_adk_rs::tasks::TaskRuntime, String> {
1065 self.compile_tasks_mode(resources, false)
1066 }
1067
1068 fn compile_tasks_mode(
1069 &self,
1070 resources: &SpecResources,
1071 offline: bool,
1072 ) -> Result<gemini_adk_rs::tasks::TaskRuntime, String> {
1073 let validation = if offline {
1074 self.validate_for_replay()
1075 } else {
1076 self.validate()
1077 };
1078 if !validation.valid {
1079 return Err(validation.errors.join("; "));
1080 }
1081 if self.skills.is_empty() {
1082 return Err("the session has no skills".into());
1083 }
1084 let skills = self
1085 .skills
1086 .iter()
1087 .map(|skill| skill.compile_mode(resources, offline, &self.models))
1088 .collect::<Result<Vec<_>, _>>()?;
1089 gemini_adk_rs::tasks::TaskRuntime::new(skills).map_err(|e| e.to_string())
1090 }
1091
1092 pub async fn trace_tasks(
1095 &self,
1096 steps: &[TaskStep],
1097 ) -> Result<
1098 (
1099 Vec<TaskTraceSnapshot>,
1100 gemini_adk_rs::tasks::TaskSessionSnapshot,
1101 ),
1102 Vec<String>,
1103 > {
1104 self.trace_tasks_with_resources(steps, &SpecResources::default())
1105 .await
1106 }
1107
1108 pub async fn trace_tasks_with_resources(
1112 &self,
1113 steps: &[TaskStep],
1114 resources: &SpecResources,
1115 ) -> Result<
1116 (
1117 Vec<TaskTraceSnapshot>,
1118 gemini_adk_rs::tasks::TaskSessionSnapshot,
1119 ),
1120 Vec<String>,
1121 > {
1122 let validation = self.validate_for_replay();
1123 if !validation.valid {
1124 return Err(validation.errors);
1125 }
1126 if self.skills.is_empty() {
1127 return Err(vec!["the session has no skills".into()]);
1128 }
1129 let mut memory_fixtures = BTreeMap::new();
1130 let mut compiled = Vec::new();
1131 for skill in &self.skills {
1132 let mut bindings = resources.clone();
1133 if skill.memory.is_some() && bindings.memory.is_none() {
1134 let fixture = Arc::new(FixtureMemory::default());
1135 bindings.memory = Some(Arc::new(FixtureMemoryBinding(fixture.clone())));
1136 memory_fixtures.insert(skill.name.clone(), fixture);
1137 }
1138 compiled.push(
1139 skill
1140 .compile_mode(&bindings, true, &super::ModelsSpec::default())
1141 .map_err(|error| vec![error])?,
1142 );
1143 }
1144 let clock = Arc::new(gemini_adk_rs::clock::ManualClock::new());
1145 let mut runtime = gemini_adk_rs::tasks::TaskRuntime::new(compiled)
1146 .map_err(|error| vec![error.to_string()])?
1147 .with_clock(clock.clone());
1148 let mut pending = BTreeMap::new();
1149 let mut pending_services: DeferredServices = BTreeMap::new();
1150 let mut snapshots = vec![TaskTraceSnapshot {
1151 index: 0,
1152 event: "initial".into(),
1153 failures: Vec::new(),
1154 service_effects: Vec::new(),
1155 deferred_services: Vec::new(),
1156 status: runtime.snapshot(),
1157 }];
1158 for (index, step) in steps.iter().enumerate() {
1159 let mut failures = Vec::new();
1160 let mut controlled = BTreeMap::new();
1161 let mut defer_services = false;
1162 let event = match step {
1163 TaskStep::Command {
1164 command,
1165 defer,
1166 expected_error,
1167 extraction,
1168 extraction_errors,
1169 } => {
1170 controlled = controlled_results(extraction, extraction_errors, &mut failures);
1171 match runtime.command(command.clone()) {
1172 Ok(invocation) => {
1173 if let Some(expected) = expected_error {
1174 failures.push(format!(
1175 "expected command error '{expected}', command succeeded"
1176 ));
1177 }
1178 if let Some(invocation) = invocation {
1179 if *defer {
1180 pending.insert(invocation.operation_id().clone(), invocation);
1181 } else {
1182 runtime.complete(invocation.execute().await);
1183 }
1184 }
1185 }
1186 Err(error) => {
1187 let message = error.to_string();
1188 if !expected_error
1189 .as_ref()
1190 .is_some_and(|expected| message.contains(expected))
1191 {
1192 failures.push(message);
1193 }
1194 }
1195 }
1196 serde_json::to_value(command)
1197 .ok()
1198 .and_then(|v| v["action"].as_str().map(str::to_owned))
1199 .unwrap_or_else(|| "command".into())
1200 }
1201 TaskStep::Turn => {
1202 runtime.on_turn();
1203 "turn".into()
1204 }
1205 TaskStep::Finish {
1206 operation,
1207 extraction,
1208 extraction_errors,
1209 } => {
1210 controlled = controlled_results(extraction, extraction_errors, &mut failures);
1211 match pending.remove(operation) {
1212 Some(invocation) => {
1213 runtime.complete(invocation.execute().await);
1214 }
1215 None => failures.push(format!("no deferred invocation '{}'", operation.0)),
1216 }
1217 format!("finish: {}", operation.0)
1218 }
1219 TaskStep::Observe {
1220 task,
1221 revision,
1222 trigger,
1223 user,
1224 model,
1225 extraction,
1226 extraction_errors,
1227 defer,
1228 } => {
1229 controlled = controlled_results(extraction, extraction_errors, &mut failures);
1230 defer_services = *defer;
1231 let revision = revision.or_else(|| {
1232 runtime
1233 .snapshot()
1234 .tasks
1235 .iter()
1236 .find(|activation| activation.id == *task)
1237 .map(|activation| activation.revision)
1238 });
1239 let event = match trigger {
1240 TaskObservationKind::Turn => gemini_adk_rs::tasks::TaskObservation::Turn {
1241 user: user.clone(),
1242 model: model.clone(),
1243 },
1244 TaskObservationKind::GenerationComplete => {
1245 gemini_adk_rs::tasks::TaskObservation::GenerationComplete {
1246 user: user.clone(),
1247 model: model.clone(),
1248 }
1249 }
1250 TaskObservationKind::Interrupted => {
1251 gemini_adk_rs::tasks::TaskObservation::Interrupted
1252 }
1253 TaskObservationKind::Timer => gemini_adk_rs::tasks::TaskObservation::Timer,
1254 };
1255 if let Some(revision) = revision {
1256 if let Err(error) = runtime.observe(task, revision, event) {
1257 failures.push(error.to_string());
1258 }
1259 } else {
1260 failures.push(format!("task '{}' does not exist", task.0));
1261 }
1262 format!("observe: {}", task.0)
1263 }
1264 TaskStep::FinishService { operation } => {
1265 match pending_services.remove(operation) {
1266 Some((work, fixtures)) => {
1267 runtime.complete_services(work.execute_controlled(fixtures).await);
1268 }
1269 None => failures.push(format!("no deferred service '{}'", operation.0)),
1270 }
1271 format!("finish_service: {}", operation.0)
1272 }
1273 TaskStep::AdvanceTime { millis } => {
1274 clock.advance(std::time::Duration::from_millis(*millis));
1275 let snapshot = runtime.snapshot();
1276 if let Some(task) = snapshot
1277 .tasks
1278 .iter()
1279 .find(|task| snapshot.foreground.as_ref() == Some(&task.id))
1280 && let Err(error) = runtime.observe(
1281 &task.id,
1282 task.revision,
1283 gemini_adk_rs::tasks::TaskObservation::Timer,
1284 )
1285 {
1286 failures.push(error.to_string());
1287 }
1288 format!("advance_time: {millis}")
1289 }
1290 TaskStep::Memory { skill, values } => {
1291 if let Some(fixture) = memory_fixtures.get(skill) {
1292 let declaration = self
1293 .skills
1294 .iter()
1295 .find(|candidate| candidate.name == *skill)
1296 .and_then(|skill| skill.memory.as_ref());
1297 if values.keys().any(|key| {
1298 !declaration.is_some_and(|memory| {
1299 memory.slots.iter().any(|slot| slot.to == *key)
1300 })
1301 }) {
1302 failures.push(format!(
1303 "memory fixture for '{skill}' contains an undeclared slot"
1304 ));
1305 } else {
1306 *fixture.values.write().expect("fixture memory lock") = json!(values);
1307 }
1308 } else {
1309 failures.push(format!("'{skill}' has no controlled replay memory adapter"));
1310 }
1311 format!("memory fixture: {skill}")
1312 }
1313 TaskStep::Expect {
1314 foreground,
1315 tasks,
1316 operations,
1317 } => {
1318 let snapshot = runtime.snapshot();
1319 if let Some(expected) = foreground
1320 && snapshot.foreground.as_ref() != Some(expected)
1321 {
1322 failures.push(format!(
1323 "expected foreground '{}'; got {:?}",
1324 expected.0, snapshot.foreground
1325 ));
1326 }
1327 for expected in tasks {
1328 let Some(task) = snapshot
1329 .tasks
1330 .iter()
1331 .find(|task| task.id.0 == expected.task)
1332 else {
1333 failures.push(format!("task '{}' does not exist", expected.task));
1334 continue;
1335 };
1336 if expected.status.is_some_and(|status| status != task.status) {
1337 failures.push(format!(
1338 "task '{}' status was {:?}",
1339 expected.task, task.status
1340 ));
1341 }
1342 if expected
1343 .version
1344 .as_ref()
1345 .is_some_and(|version| version != &task.skill.version)
1346 {
1347 failures.push(format!(
1348 "task '{}' version was '{}'",
1349 expected.task, task.skill.version
1350 ));
1351 }
1352 if expected
1353 .revision
1354 .is_some_and(|revision| revision != task.revision)
1355 {
1356 failures.push(format!(
1357 "task '{}' revision was {}",
1358 expected.task, task.revision
1359 ));
1360 }
1361 if expected
1362 .output
1363 .as_ref()
1364 .is_some_and(|output| Some(output) != task.output.as_ref())
1365 {
1366 failures.push(format!(
1367 "task '{}' output was {:?}",
1368 expected.task, task.output
1369 ));
1370 }
1371 for step in &expected.active {
1372 if !task
1373 .flow
1374 .as_ref()
1375 .is_some_and(|flow| flow.explanation.active.contains(step))
1376 {
1377 failures.push(format!(
1378 "task '{}' expected active step '{step}'",
1379 expected.task
1380 ));
1381 }
1382 }
1383 for step in &expected.done {
1384 if !task
1385 .flow
1386 .as_ref()
1387 .is_some_and(|flow| flow.done.contains(step))
1388 {
1389 failures.push(format!(
1390 "task '{}' expected done step '{step}'",
1391 expected.task
1392 ));
1393 }
1394 }
1395 }
1396 for (id, expected) in operations {
1397 if !snapshot
1398 .operations
1399 .iter()
1400 .any(|operation| operation.id.0 == *id && operation.status == *expected)
1401 {
1402 failures.push(format!("operation '{id}' expected {expected:?}"));
1403 }
1404 }
1405 "expect".into()
1406 }
1407 };
1408 drain_services(
1409 &mut runtime,
1410 &mut controlled,
1411 defer_services,
1412 &mut pending_services,
1413 )
1414 .await;
1415 for name in controlled.keys() {
1416 failures.push(format!("unused controlled extraction '{name}'"));
1417 }
1418 let service_effects = runtime.take_service_effects();
1419 for effect in &service_effects {
1420 if let TaskEffect::Error(error) = &effect.effect {
1421 failures.push(error.clone());
1422 }
1423 }
1424 snapshots.push(TaskTraceSnapshot {
1425 index: index + 1,
1426 event,
1427 failures,
1428 service_effects,
1429 deferred_services: pending_services
1430 .values()
1431 .map(|(work, _)| work.owner().clone())
1432 .collect(),
1433 status: runtime.snapshot(),
1434 });
1435 }
1436 Ok((snapshots, runtime.snapshot()))
1437 }
1438
1439 pub async fn run_task_scenarios(&self) -> Vec<super::ScenarioReport> {
1441 let mut reports = Vec::new();
1442 for scenario in &self.task_scenarios {
1443 let error = match self.trace_tasks(&scenario.steps).await {
1444 Ok((snapshots, _)) => snapshots
1445 .into_iter()
1446 .find(|snapshot| !snapshot.failures.is_empty())
1447 .map(|snapshot| {
1448 format!("event {}: {}", snapshot.index, snapshot.failures.join("; "))
1449 }),
1450 Err(errors) => Some(errors.join("; ")),
1451 };
1452 reports.push(super::ScenarioReport {
1453 name: scenario.name.clone(),
1454 passed: error.is_none(),
1455 error,
1456 });
1457 }
1458 reports
1459 }
1460}
1461
1462type ControlledExtraction = BTreeMap<String, Result<Value, String>>;
1463type DeferredServices = BTreeMap<
1464 gemini_adk_rs::tasks::OperationId,
1465 (
1466 gemini_adk_rs::tasks::OwnedTaskServiceWork,
1467 ControlledExtraction,
1468 ),
1469>;
1470fn controlled_results(
1471 values: &BTreeMap<String, Value>,
1472 errors: &BTreeMap<String, String>,
1473 failures: &mut Vec<String>,
1474) -> ControlledExtraction {
1475 let mut results: ControlledExtraction = values
1476 .iter()
1477 .map(|(name, value)| (name.clone(), Ok(value.clone())))
1478 .collect();
1479 for (name, error) in errors {
1480 if results.insert(name.clone(), Err(error.clone())).is_some() {
1481 failures.push(format!(
1482 "extractor '{name}' has both a result and an error fixture"
1483 ));
1484 }
1485 }
1486 results
1487}
1488async fn drain_services(
1489 runtime: &mut gemini_adk_rs::tasks::TaskRuntime,
1490 fixtures: &mut ControlledExtraction,
1491 defer: bool,
1492 pending: &mut DeferredServices,
1493) {
1494 loop {
1495 let jobs = runtime.take_ready_services();
1496 if jobs.is_empty() {
1497 break;
1498 }
1499 for work in jobs {
1500 let selected = work
1501 .extractor_names()
1502 .into_iter()
1503 .filter_map(|name| fixtures.remove_entry(name))
1504 .collect();
1505 if defer {
1506 pending.insert(work.owner().operation.clone(), (work, selected));
1507 } else {
1508 runtime.complete_services(work.execute_controlled(selected).await);
1509 }
1510 }
1511 }
1512}
1513#[derive(Default)]
1514struct FixtureMemory {
1515 values: std::sync::RwLock<Value>,
1516}
1517impl FixtureMemory {
1518 fn projection(&self) -> Value {
1519 let value = self.values.read().expect("fixture memory lock").clone();
1520 if value.is_object() { value } else { json!({}) }
1521 }
1522}
1523#[async_trait::async_trait]
1524impl gemini_adk_rs::tasks::TaskMemoryService for FixtureMemory {
1525 async fn project(&self) -> Result<Value, String> {
1526 Ok(self.projection())
1527 }
1528 async fn ingest(&self, _id: String, _turn: u64, _user: String) -> Result<Value, String> {
1529 Ok(self.projection())
1530 }
1531 async fn remember(&self, _id: String, _turn: u64, _note: String) -> Result<Value, String> {
1532 Ok(self.projection())
1533 }
1534}
1535struct FixtureMemoryBinding(Arc<FixtureMemory>);
1536impl super::MemoryBinding for FixtureMemoryBinding {
1537 fn install(&self, live: crate::live::Live, _memory: &super::MemorySpec) -> crate::live::Live {
1538 live
1539 }
1540 fn remember(&self, _note: String) {}
1541 fn task_memory(
1542 &self,
1543 _memory: &super::MemorySpec,
1544 ) -> Result<Arc<dyn gemini_adk_rs::tasks::TaskMemoryService>, String> {
1545 Ok(self.0.clone())
1546 }
1547}
1548
1549#[cfg(test)]
1550mod tests {
1551 use super::*;
1552 use gemini_adk_rs::tasks::{TaskCommand, TaskId};
1553
1554 fn simple() -> SessionSpec {
1555 SessionSpec::from_value(json!({
1556 "name":"support",
1557 "skills":[{"name":"faq","version":"1","instruction":"Answer.",
1558 "inputs":{"question":{"type":"string"}},
1559 "outputs":{"result":{"type":"object"}},
1560 "tools":[{"name":"lookup","effect":{"kind":"read"},
1561 "response":{"answer":"help"},"save_response_as":"result"}]}]
1562 }))
1563 .expect("parses")
1564 }
1565
1566 #[test]
1567 fn effect_policies_and_skill_versions_are_explicit() {
1568 let mut spec = simple();
1569 spec.skills[0].version.clear();
1570 assert!(!spec.validate().valid);
1571 let mut value = serde_json::to_value(simple()).expect("serializes");
1572 value["skills"][0]["tools"][0]
1573 .as_object_mut()
1574 .expect("tool")
1575 .remove("effect");
1576 assert!(SessionSpec::from_value(value).is_err());
1577 }
1578
1579 #[test]
1580 fn authoring_and_runtime_share_provider_name_validation() {
1581 let mut spec = simple();
1582 spec.skills[0].name = "faq__nested".into();
1583 assert!(!spec.validate().valid);
1584 let mut spec = simple();
1585 spec.skills[0].tools[0].tool.name = "x".repeat(62);
1586 assert!(
1587 !spec.validate().valid,
1588 "qualified names are at most 64 bytes"
1589 );
1590 let mut spec = simple();
1591 spec.skills[0].tools[0].tool.background = true;
1592 assert!(
1593 spec.validate()
1594 .errors
1595 .iter()
1596 .any(|error| error.contains("delivery"))
1597 );
1598 }
1599
1600 #[test]
1601 fn required_activation_inputs_are_writers_but_unproduced_state_still_warns() {
1602 let mut spec = simple();
1603 spec.skills[0].state.insert(
1604 "missing".into(),
1605 serde_json::from_value(json!({"type":"string"})).expect("declared field"),
1606 );
1607 spec.skills[0].flow = Some(
1608 serde_json::from_value(json!({
1609 "steps":[
1610 {"id":"collect","allow":["lookup"],"done":{"captured":["question","missing"]}},
1611 {"id":"done","after":["collect"],"terminal":true}
1612 ],
1613 "constraints":[{"require":["done"]}]
1614 }))
1615 .expect("flow"),
1616 );
1617 let validation = spec.validate();
1618 assert!(validation.valid, "{:?}", validation.errors);
1619 assert!(
1620 !validation
1621 .warnings
1622 .iter()
1623 .any(|warning| warning.contains("guard reads state key 'question'")),
1624 "a required activation input is populated by the runtime"
1625 );
1626 assert!(
1627 validation
1628 .warnings
1629 .iter()
1630 .any(|warning| warning.contains("guard reads state key 'missing'")),
1631 "declaring a non-input state field does not create a producer"
1632 );
1633 assert!(
1634 spec.skills[0]
1635 .definition()
1636 .validate()
1637 .warnings
1638 .iter()
1639 .any(|warning| warning.contains("guard reads state key 'question'")),
1640 "ordinary session state retains the missing-producer warning"
1641 );
1642 }
1643
1644 #[test]
1645 fn commit_declarations_advertise_the_required_idempotency_argument() {
1646 let mut spec = simple();
1647 spec.skills[0].tools[0].effect = TaskToolEffect::Commit {
1648 idempotency_argument: "request_id".into(),
1649 };
1650 let compiled = spec.skills[0]
1651 .compile(&SpecResources::default())
1652 .expect("compiles");
1653 let parameters = compiled.tools[0]
1654 .parameters
1655 .as_ref()
1656 .expect("commit schema");
1657 assert_eq!(parameters["type"], "object");
1658 assert_eq!(parameters["properties"]["request_id"]["type"], "string");
1659 assert_eq!(parameters["required"], json!(["request_id"]));
1660 let dispatcher = (compiled.tool_factory)(State::new()).expect("binds");
1661 let gemini_adk_rs::tool::ToolKind::Function(tool) =
1662 dispatcher.get_tool("lookup").expect("tool")
1663 else {
1664 panic!("declared task tools are function tools");
1665 };
1666 assert_eq!(tool.parameters(), Some(parameters.clone()));
1667 spec.skills[0].tools[0].tool.parameters = Some(json!({
1668 "type":"object","properties":{"request_id":{"type":"number"}}
1669 }));
1670 assert!(!spec.validate().valid, "incompatible authored field fails");
1671 }
1672
1673 #[test]
1674 fn contracts_and_ambiguous_global_pipelines_fail_at_load() {
1675 let mut spec = simple();
1676 spec.skills[0]
1677 .inputs
1678 .get_mut("question")
1679 .expect("field")
1680 .default = Some(json!(5));
1681 assert!(!spec.validate().valid);
1682 let mut spec = simple();
1683 spec.flow = Some(Flow::new().step("global").terminal().build().expect("flow"));
1684 assert!(
1685 spec.validate()
1686 .errors
1687 .iter()
1688 .any(|e| e.contains("session-level pipeline"))
1689 );
1690 let mut spec = simple();
1691 spec.state.insert(
1692 "question".into(),
1693 serde_json::from_value(json!({"type":"string","default":"root context"}))
1694 .expect("field"),
1695 );
1696 assert!(
1697 spec.validate()
1698 .errors
1699 .iter()
1700 .any(|error| error.contains("session-level pipeline"))
1701 );
1702 }
1703
1704 #[test]
1705 fn overlapping_contracts_accept_matching_types_and_reject_conflicting_defaults() {
1706 let mut spec = simple();
1707 let field: StateFieldSpec =
1708 serde_json::from_value(json!({"type":"string","default":"same"})).expect("field");
1709 spec.skills[0]
1710 .inputs
1711 .insert("question".into(), field.clone());
1712 spec.skills[0]
1713 .outputs
1714 .insert("question".into(), field.clone());
1715 spec.skills[0].state.insert("question".into(), field);
1716 assert!(spec.validate().valid, "matching overlap is supported");
1717 spec.skills[0]
1718 .outputs
1719 .get_mut("question")
1720 .expect("field")
1721 .default = Some(json!("different"));
1722 assert!(
1723 spec.validate()
1724 .errors
1725 .iter()
1726 .any(|error| error.contains("conflicting defaults"))
1727 );
1728 }
1729
1730 #[tokio::test]
1731 async fn replay_checks_inputs_and_declared_output_without_external_effects() {
1732 let spec = simple();
1733 let steps: Vec<TaskStep> = serde_json::from_value(json!([
1734 {"event":"command","command":{"action":"start","skill":"faq","input":{"question":5}},"expected_error":"wrong type"},
1735 {"event":"command","command":{"action":"start","skill":"faq","input":{"question":"Help"}}},
1736 {"event":"command","command":{"action":"complete","task":"task-1","output":{}},"expected_error":"required field"},
1737 {"event":"command","command":{"action":"invoke","task":"task-1","tool":"lookup","args":{}}},
1738 {"event":"command","command":{"action":"complete","task":"task-1","output":{"should_not_export":true}}},
1739 {"event":"expect","tasks":[{"task":"task-1","status":"completed","output":{"result":{"answer":"help"}}}]}
1740 ])).expect("steps");
1741 let (snapshots, status) = spec.trace_tasks(&steps).await.expect("traces");
1742 assert!(
1743 snapshots.iter().all(|s| s.failures.is_empty()),
1744 "{:?}",
1745 snapshots.iter().map(|s| &s.failures).collect::<Vec<_>>()
1746 );
1747 assert_eq!(
1748 status.tasks.len(),
1749 1,
1750 "rejected activation allocates no task"
1751 );
1752 let (initial, status) = spec.trace_tasks(&[]).await.expect("initial");
1753 assert_eq!(initial.len(), 1);
1754 assert_eq!(status.skills[0].key.version, "1");
1755 }
1756
1757 #[tokio::test]
1758 async fn custom_tool_context_is_owned_and_bound_to_private_invocation_state() {
1759 let resources = SpecResources::default().implement_skill(
1760 "faq",
1761 ContextTool::new("lookup", "lookup", None, |args, ctx| async move {
1762 let owner = ctx.task.as_ref().expect("task ownership forwarded");
1763 let question: String = ctx
1764 .state
1765 .try_get("question")
1766 .map_err(|e| gemini_adk_rs::ToolError::Other(e.to_string()))?
1767 .ok_or_else(|| gemini_adk_rs::ToolError::Other("missing question".into()))?;
1768 assert!(args.is_object());
1769 Ok(json!({"task":owner.task.0,"question":question}))
1770 }),
1771 );
1772 let mut runtime = simple().compile_tasks(&resources).expect("compiles");
1773 for index in 1..=2 {
1774 runtime
1775 .command(TaskCommand::Start {
1776 skill: "faq".into(),
1777 input: json!({"question":format!("question-{index}")}),
1778 parent: None,
1779 })
1780 .expect("starts");
1781 let id = TaskId(format!("task-{index}"));
1782 let invocation = runtime
1783 .command(TaskCommand::Invoke {
1784 task: id.clone(),
1785 tool: "lookup".into(),
1786 args: json!({}),
1787 idempotency_key: None,
1788 })
1789 .expect("admits")
1790 .expect("invocation");
1791 let delivery = runtime.complete(invocation.execute().await);
1792 assert!(!delivery.stale);
1793 runtime
1794 .command(TaskCommand::Complete {
1795 task: id,
1796 output: json!({}),
1797 })
1798 .expect("completes");
1799 }
1800 let snapshot = runtime.snapshot();
1801 assert_eq!(
1802 snapshot.tasks[0].output,
1803 Some(json!({"result":{"task":"task-1","question":"question-1"}}))
1804 );
1805 assert_eq!(
1806 snapshot.tasks[1].output,
1807 Some(json!({"result":{"task":"task-2","question":"question-2"}}))
1808 );
1809 }
1810
1811 #[tokio::test]
1812 async fn reconciled_commit_applies_declared_state_without_reexecuting_tool() {
1813 use gemini_adk_rs::tasks::{OperationStatus, ReconciledOutcome};
1814 use std::sync::atomic::{AtomicUsize, Ordering};
1815
1816 let mut spec = simple();
1817 spec.skills[0].tools[0].effect = TaskToolEffect::Commit {
1818 idempotency_argument: "request_id".into(),
1819 };
1820 spec.skills[0].tools[0]
1821 .tool
1822 .set_state
1823 .insert("accepted".into(), json!(true));
1824 spec.skills[0].outputs.insert(
1825 "accepted".into(),
1826 serde_json::from_value(json!({"type":"boolean"})).expect("field"),
1827 );
1828 spec.skills[0].outputs.insert(
1829 "unverified".into(),
1830 serde_json::from_value(json!({"type":"boolean","default":false})).expect("field"),
1831 );
1832 let calls = Arc::new(AtomicUsize::new(0));
1833 let counted = calls.clone();
1834 let resources = SpecResources::default().implement_skill(
1835 "faq",
1836 ContextTool::new("lookup", "lookup", None, move |_args, ctx| {
1837 let counted = counted.clone();
1838 async move {
1839 counted.fetch_add(1, Ordering::SeqCst);
1840 ctx.state
1841 .set("unverified", true)
1842 .map_err(|error| gemini_adk_rs::ToolError::Other(error.to_string()))?;
1843 Err(gemini_adk_rs::ToolError::Other("connection lost".into()))
1844 }
1845 }),
1846 );
1847 let mut runtime = spec.compile_tasks(&resources).expect("compiles");
1848 runtime
1849 .command(TaskCommand::Start {
1850 skill: "faq".into(),
1851 input: json!({"question":"Help"}),
1852 parent: None,
1853 })
1854 .expect("starts");
1855 runtime
1856 .command(TaskCommand::Invoke {
1857 task: TaskId("task-1".into()),
1858 tool: "lookup".into(),
1859 args: json!({"request_id":"receipt-1"}),
1860 idempotency_key: None,
1861 })
1862 .expect("awaits approval");
1863 let operation = runtime.snapshot().operations[0].id.clone();
1864 let invocation = runtime
1865 .command(TaskCommand::Decide {
1866 operation: operation.clone(),
1867 approve: true,
1868 })
1869 .expect("approves")
1870 .expect("invocation");
1871 runtime.complete(invocation.execute().await);
1872 assert_eq!(
1873 runtime.snapshot().operations[0].status,
1874 OperationStatus::Unknown
1875 );
1876 let verified = json!({"receipt":"externally-verified"});
1877 runtime
1878 .command(TaskCommand::Reconcile {
1879 operation,
1880 outcome: ReconciledOutcome::Succeeded {
1881 result: verified.clone(),
1882 },
1883 })
1884 .expect("reconstructs accepted state");
1885 runtime
1886 .command(TaskCommand::Complete {
1887 task: TaskId("task-1".into()),
1888 output: json!({}),
1889 })
1890 .expect("exports reconstructed response");
1891 assert_eq!(
1892 runtime.snapshot().tasks[0].output,
1893 Some(json!({"result":verified,"accepted":true,"unverified":false}))
1894 );
1895 assert_eq!(calls.load(Ordering::SeqCst), 1, "reconciliation is pure");
1896 }
1897
1898 #[tokio::test]
1899 async fn support_demo_runs_shared_task_journeys() {
1900 let spec: SessionSpec = serde_json::from_str(include_str!(
1901 "../../../../apps/gemini-adk-web-rs/static/examples/flows/support-agent.json"
1902 ))
1903 .expect("demo parses");
1904 let validation = spec.validate();
1905 assert!(validation.valid, "{:?}", validation.errors);
1906 assert_eq!(validation.steps, 5, "billing and diagnostics governance");
1907 assert_eq!(
1908 validation.tools,
1909 [
1910 "billing__verify_account",
1911 "billing__submit_adjustment",
1912 "faq__search_faq",
1913 "diagnostics__run_diagnostics",
1914 "handoff__transfer_case",
1915 ]
1916 );
1917 for report in spec.run_task_scenarios().await {
1918 assert!(report.passed, "{}: {:?}", report.name, report.error);
1919 }
1920 }
1921
1922 #[test]
1923 fn session_workflow_tests_include_qualified_skill_passes_and_failures() {
1924 let mut spec = simple();
1925 spec.skills[0].flow = Some(
1926 serde_json::from_value(json!({
1927 "steps":[
1928 {"id":"retrieve","allow":["lookup"],"done":{"called_ok":"lookup"}},
1929 {"id":"done","after":["retrieve"],"terminal":true}
1930 ],
1931 "constraints":[{"require":["done"]}]
1932 }))
1933 .expect("governed flow"),
1934 );
1935 spec.skills[0].tests = serde_json::from_value(json!([
1936 {"name":"retrieval","script":[{"tool":"lookup"},{"expect":{"complete":true}}]},
1937 {"name":"unfilled","script":[{"expect":{"complete":true}}]}
1938 ]))
1939 .expect("workflow tests");
1940 assert!(spec.validate().valid);
1941 let reports = spec.run_tests();
1942 assert_eq!(reports.len(), 2, "each child test runs once");
1943 assert_eq!(reports[0].name, "faq/retrieval");
1944 assert!(reports[0].passed, "{:?}", reports[0].failures);
1945 assert_eq!(reports[1].name, "faq/unfilled");
1946 assert!(!reports[1].passed);
1947 assert!(!reports[1].failures.is_empty());
1948 assert_eq!(spec.skills[0].run_tests()[0].name, "retrieval");
1949 }
1950
1951 #[test]
1952 fn recursive_sandbox_preserves_effect_policy_and_mock_state_writes() {
1953 let mut spec = simple();
1954 spec.skills[0].tools[0].tool.http = Some(
1955 serde_json::from_value(json!({"url":"http://127.0.0.1:1/blocked"})).expect("binding"),
1956 );
1957 let (safe, notes) = spec.sandboxed(&super::super::BindingAllowlist::default());
1958 assert!(safe.skills[0].tools[0].tool.http.is_none());
1959 assert!(matches!(
1960 safe.skills[0].tools[0].effect,
1961 TaskToolEffect::Read
1962 ));
1963 assert_eq!(
1964 safe.skills[0].tools[0].tool.save_response_as.as_deref(),
1965 Some("result")
1966 );
1967 assert!(notes[0].contains("skill 'faq'"));
1968 }
1969
1970 #[test]
1971 fn unsupported_task_owned_extraction_and_redaction_fail_explicitly() {
1972 let mut spec = simple();
1973 spec.skills[0].conversation = Some(
1974 serde_json::from_value(json!({
1975 "name":"unsupported",
1976 "stages":[{"id":"resolve","resolve":[{"slot":"answer","resolver":"lookup"}]}],
1977 "policies":[{"kind":"redact","keys":["question"]}]
1978 }))
1979 .expect("conversation"),
1980 );
1981 let errors = spec.skills[0]
1982 .validate()
1983 .expect_err("unsupported behavior must fail");
1984 assert!(
1985 errors
1986 .iter()
1987 .any(|error| error.contains("stage frame extraction"))
1988 );
1989 assert!(errors.iter().any(|error| error.contains("redaction")));
1990 }
1991
1992 #[tokio::test]
1993 async fn offline_validation_keeps_conflicting_bindings_visible() {
1994 let mut spec = simple();
1995 spec.skills[0].tools[0].tool.http = Some(
1996 serde_json::from_value(json!({"url":"http://127.0.0.1:1/no-network"}))
1997 .expect("binding"),
1998 );
1999 spec.skills[0].tools[0].tool.mcp = Some("would-run-an-external-command".into());
2000 let errors = spec
2001 .trace_tasks(&[])
2002 .await
2003 .expect_err("conflicting bindings fail before mock replay");
2004 assert!(errors[0].contains("both an http and an mcp binding"));
2005 }
2006 fn service_spec() -> SessionSpec {
2007 SessionSpec::from_value(json!({
2008 "name":"owned-services", "skills":[{
2009 "name":"intake","version":"1",
2010 "outputs":{"name":{"type":"string"},"urgent":{"type":"boolean"},
2011 "ready":{"type":"boolean"},"noticed":{"type":"boolean"},"stuck":{"type":"boolean"}},
2012 "state":{"urgent":{"type":"boolean","default":false},
2013 "noticed":{"type":"boolean","default":false},"stuck":{"type":"boolean","default":false}},
2014 "extract":[{"name":"details","instruction":"Extract the name and urgency.","window":2,
2015 "schema":{"type":"object","properties":{"person":{"type":"string"},"urgent":{"type":"boolean"}}},
2016 "promote":[{"field":"person","to":"name"},{"field":"urgent","policy":"true_only"}]}],
2017 "computed":[{"key":"ready","from":{"key":"urgent"}}],
2018 "watch":[{"key":"ready","condition":"became_true","set":{"noticed":true},
2019 "effects":[{"context":"Urgency has been captured."},{"prompt":"Confirm the callback."}]}],
2020 "patterns":[{"name":"two-urgent-turns","when":{"is_true":"urgent"},"turns":2,
2021 "effects":[{"set":{"stuck":true}}]}]
2022 }]
2023 })).expect("service spec")
2024 }
2025
2026 async fn replay_service_steps(spec: &SessionSpec, steps: Value) -> Vec<TaskTraceSnapshot> {
2027 let steps: Vec<TaskStep> = serde_json::from_value(steps).expect("service steps");
2028 let (trace, _) = spec.trace_tasks(&steps).await.expect("service replay");
2029 assert!(
2030 trace.iter().all(|snapshot| snapshot.failures.is_empty()),
2031 "{trace:#?}"
2032 );
2033 trace
2034 }
2035
2036 #[tokio::test]
2037 async fn task_speech_uses_promotions_computed_watchers_and_turn_patterns() {
2038 let spec = service_spec();
2039 assert!(spec.requires_extraction());
2040 assert!(!spec.requires_memory());
2041 assert!(spec.validate().valid, "{:?}", spec.validate().errors);
2042 assert!(
2043 spec.compile_tasks(&SpecResources::default())
2044 .err()
2045 .expect("missing resources")
2046 .contains("extraction_llm")
2047 );
2048 let trace = replay_service_steps(&spec,json!([
2049 {"event":"command","command":{"action":"start","skill":"intake"}},
2050 {"event":"observe","task":"task-1","user":"My name is Ada and this is urgent",
2051 "extraction":{"details":{"person":"Ada","urgent":true}}},
2052 {"event":"observe","task":"task-1","user":"I actually meant another name but urgency remains",
2053 "extraction":{"details":{"person":"Grace","urgent":false}}},
2054 {"event":"command","command":{"action":"complete","task":"task-1","output":{}}},
2055 {"event":"expect","tasks":[{"task":"task-1","status":"completed",
2056 "output":{"name":"Ada","urgent":true,"ready":true,"noticed":true,"stuck":true}}]}
2057 ])).await;
2058 let context_count = trace
2059 .iter()
2060 .flat_map(|snapshot| &snapshot.service_effects)
2061 .filter(|effect| matches!(effect.effect, TaskEffect::Context(_)))
2062 .count();
2063 assert_eq!(
2064 context_count, 1,
2065 "computed watcher fires once for the accepted change"
2066 );
2067 }
2068
2069 #[tokio::test]
2070 async fn task_service_replay_keeps_minimum_words_and_reports_unused_fixtures() {
2071 let spec = service_spec();
2072 let steps: Vec<TaskStep> = serde_json::from_value(json!([
2073 {"event":"command","command":{"action":"start","skill":"intake"}},
2074 {"event":"observe","task":"task-1","user":"Hi","extraction":{"details":{"person":"Ada"}}}
2075 ])).expect("steps");
2076 let (trace, _) = spec.trace_tasks(&steps).await.expect("trace");
2077 assert!(
2078 trace[2]
2079 .failures
2080 .iter()
2081 .any(|failure| failure.contains("unused controlled extraction"))
2082 );
2083 assert!(
2084 trace[2].service_effects.is_empty(),
2085 "skipped extraction never promotes fixtures"
2086 );
2087 }
2088
2089 #[tokio::test]
2090 async fn task_service_replay_rejects_late_revision_results_and_isolates_activations() {
2091 let mut spec = service_spec();
2092 spec.skills[0].outputs.clear();
2093 let trace = replay_service_steps(&spec,json!([
2094 {"event":"command","command":{"action":"start","skill":"intake"}},
2095 {"event":"observe","task":"task-1","user":"This is urgent and my name is Ada",
2096 "extraction":{"details":{"person":"Ada","urgent":true}},"defer":true},
2097 {"event":"command","command":{"action":"revise","task":"task-1","expected_revision":1,"input":{}}},
2098 {"event":"finish_service","operation":"service-2"},
2099 {"event":"command","command":{"action":"start","skill":"intake","parent":"task-1"}},
2100 {"event":"observe","task":"task-2","user":"My name is Grace and it is not urgent",
2101 "extraction":{"details":{"person":"Grace","urgent":false}}},
2102 {"event":"command","command":{"action":"complete","task":"task-2","output":{}}},
2103 {"event":"expect","foreground":"task-1","tasks":[{"task":"task-1","revision":2,"status":"running"}]}
2104 ])).await;
2105 assert!(
2106 !trace
2107 .iter()
2108 .flat_map(|snapshot| &snapshot.service_effects)
2109 .any(|effect| matches!(effect.effect, TaskEffect::Prompt(_))),
2110 "discarded worker does not fire watchers"
2111 );
2112 }
2113
2114 #[tokio::test]
2115 async fn task_generation_and_after_tool_extraction_use_their_declared_triggers() {
2116 let mut value = serde_json::to_value(service_spec()).expect("serialize");
2117 let skill = &mut value["skills"][0];
2118 skill["computed"] = json!([]);
2119 skill["watch"] = json!([]);
2120 skill["patterns"] = json!([]);
2121 skill["outputs"] = json!({"name":{"type":"string"},"urgent":{"type":"boolean"}});
2122 skill["tools"] =
2123 json!([{ "name":"lookup","effect":{"kind":"read"},"response":{"ok":true}}]);
2124 skill["extract"][0]["trigger"] = json!("on_generation_complete");
2125 let mut after = skill["extract"][0].clone();
2126 after["name"] = json!("after_lookup");
2127 after["trigger"] = json!("after_tool_call");
2128 skill["extract"]
2129 .as_array_mut()
2130 .expect("extract")
2131 .push(after);
2132 let spec = SessionSpec::from_value(value).expect("spec");
2133 replay_service_steps(&spec,json!([
2134 {"event":"command","command":{"action":"start","skill":"intake"}},
2135 {"event":"observe","task":"task-1","user":"My name is Ada and not urgent"},
2136 {"event":"observe","task":"task-1","trigger":"generation_complete","user":"My name is Ada and not urgent",
2137 "model":"I can help you","extraction":{"details":{"person":"Ada","urgent":false}}},
2138 {"event":"command","command":{"action":"invoke","task":"task-1","tool":"lookup","args":{}},
2139 "extraction":{"after_lookup":{"person":"Grace","urgent":true}}},
2140 {"event":"command","command":{"action":"complete","task":"task-1","output":{}}},
2141 {"event":"expect","tasks":[{"task":"task-1","output":{"name":"Ada","urgent":true}}]}
2142 ])).await;
2143 }
2144
2145 #[tokio::test]
2146 async fn task_memory_fixtures_project_only_declared_typed_slots() {
2147 let spec = SessionSpec::from_value(json!({"name":"memory","skills":[{
2148 "name":"profile","version":"1","memory":{"slots":[{"predicate":"preferred_name","to":"name"}]},
2149 "outputs":{"name":{"type":"string"}}
2150 }]})).expect("spec");
2151 assert!(spec.requires_memory());
2152 assert!(
2153 spec.compile_tasks(&SpecResources::default())
2154 .err()
2155 .expect("missing resources")
2156 .contains("SpecResources.memory")
2157 );
2158 let trace = replay_service_steps(
2159 &spec,
2160 json!([
2161 {"event":"memory","skill":"profile","values":{"name":"Ada"}},
2162 {"event":"command","command":{"action":"start","skill":"profile"}},
2163 {"event":"command","command":{"action":"complete","task":"task-1","output":{}}},
2164 {"event":"expect","tasks":[{"task":"task-1","output":{"name":"Ada"}}]}
2165 ]),
2166 )
2167 .await;
2168 let catalog = &trace[0].status.skills[0];
2169 assert_eq!(catalog.tools.len(), 2);
2170 assert!(matches!(
2171 catalog
2172 .tools
2173 .iter()
2174 .find(|tool| tool.name == "manage_memory")
2175 .expect("memory tool")
2176 .effect,
2177 TaskToolEffect::Commit { .. }
2178 ));
2179 let wrong: Vec<TaskStep> = serde_json::from_value(json!([
2180 {"event":"memory","skill":"profile","values":{"name":5}},
2181 {"event":"command","command":{"action":"start","skill":"profile"}}
2182 ]))
2183 .expect("steps");
2184 let (trace, _) = spec.trace_tasks(&wrong).await.expect("trace");
2185 assert!(
2186 trace[2]
2187 .failures
2188 .iter()
2189 .any(|failure| failure.contains("wrong type"))
2190 );
2191 }
2192
2193 #[tokio::test]
2194 async fn task_temporal_service_uses_manual_time_and_fresh_activation_detectors() {
2195 let spec = SessionSpec::from_value(json!({"name":"temporal","skills":[{
2196 "name":"wait","version":"1","inputs":{"waiting":{"type":"boolean"}},
2197 "outputs":{"nudged":{"type":"boolean"}},"state":{"nudged":{"type":"boolean","default":false}},
2198 "patterns":[{"name":"waiting-five-seconds","when":{"is_true":"waiting"},"sustained_secs":5,
2199 "effects":[{"set":{"nudged":true}}]}]
2200 }]})).expect("spec");
2201 replay_service_steps(&spec,json!([
2202 {"event":"command","command":{"action":"start","skill":"wait","input":{"waiting":true}}},
2203 {"event":"observe","task":"task-1","trigger":"timer"},
2204 {"event":"advance_time","millis":5000},
2205 {"event":"command","command":{"action":"complete","task":"task-1","output":{}}},
2206 {"event":"expect","tasks":[{"task":"task-1","output":{"nudged":true}}]},
2207 {"event":"command","command":{"action":"start","skill":"wait","input":{"waiting":true}}},
2208 {"event":"observe","task":"task-2","trigger":"timer"},
2209 {"event":"command","command":{"action":"complete","task":"task-2","output":{}}},
2210 {"event":"expect","tasks":[{"task":"task-2","output":{"nudged":false}}]}
2211 ])).await;
2212 }
2213
2214 #[test]
2215 fn task_services_roundtrip_and_reject_phase_triggers_before_activation() {
2216 let spec = service_spec();
2217 let encoded = serde_json::to_value(&spec).expect("serialize");
2218 let roundtrip = SessionSpec::from_value(encoded.clone()).expect("parse");
2219 assert_eq!(
2220 serde_json::to_value(&roundtrip).expect("serialize"),
2221 encoded
2222 );
2223 let mut spec = roundtrip;
2224 spec.skills[0].extract[0].trigger = super::super::TriggerSpec::OnPhaseChange;
2225 assert!(
2226 spec.validate()
2227 .errors
2228 .iter()
2229 .any(|error| error.contains("on_phase_change"))
2230 );
2231 }
2232 #[test]
2233 fn task_memory_installs_shared_lifecycle_once_for_multiple_skills() {
2234 use std::sync::atomic::{AtomicUsize, Ordering};
2235 struct Binding {
2236 installs: Arc<AtomicUsize>,
2237 }
2238 impl super::super::MemoryBinding for Binding {
2239 fn install(
2240 &self,
2241 _live: crate::live::Live,
2242 _memory: &super::super::MemorySpec,
2243 ) -> crate::live::Live {
2244 panic!("task mode must not install global memory services")
2245 }
2246 fn remember(&self, _note: String) {
2247 panic!("task remember uses owned work")
2248 }
2249 fn task_memory(
2250 &self,
2251 _memory: &super::super::MemorySpec,
2252 ) -> Result<Arc<dyn gemini_adk_rs::tasks::TaskMemoryService>, String> {
2253 Ok(Arc::new(FixtureMemory::default()))
2254 }
2255 fn task_tools(&self) -> Vec<Arc<dyn ToolFunction>> {
2256 super::super::MEMORY_TOOL_NAMES
2257 .into_iter()
2258 .map(|name| {
2259 Arc::new(gemini_adk_rs::tool::SimpleTool::new(
2260 name,
2261 "Controlled memory",
2262 None,
2263 |_| async { Ok(json!({})) },
2264 )) as Arc<dyn ToolFunction>
2265 })
2266 .collect()
2267 }
2268 fn install_task_lifecycle(&self, live: crate::live::Live) -> crate::live::Live {
2269 self.installs.fetch_add(1, Ordering::SeqCst);
2270 live
2271 }
2272 }
2273 let spec = SessionSpec::from_value(json!({"name":"memory","skills":[
2274 {"name":"profile","version":"1","memory":{}},
2275 {"name":"preferences","version":"1","memory":{}}
2276 ]}))
2277 .expect("spec");
2278 let installs = Arc::new(AtomicUsize::new(0));
2279 let resources = SpecResources {
2280 memory: Some(Arc::new(Binding {
2281 installs: installs.clone(),
2282 })),
2283 ..Default::default()
2284 };
2285 assert!(
2286 spec.apply(crate::live::Live::builder(), &State::new(), &resources)
2287 .is_ok()
2288 );
2289 assert_eq!(installs.load(Ordering::SeqCst), 1);
2290 }
2291 #[test]
2292 fn task_services_cannot_author_writes_into_runtime_storage() {
2293 let mut spec = service_spec();
2294 spec.skills[0].extract[0].promote[0].to = Some("task:input".into());
2295 spec.skills[0].watch[0]
2296 .set
2297 .insert("flow:done:gate".into(), json!(true));
2298 spec.skills[0].patterns[0]
2299 .effects
2300 .push(super::super::EffectSpec::Set(BTreeMap::from([(
2301 "derived:ready".into(),
2302 json!(true),
2303 )])));
2304 let errors = spec.validate().errors;
2305 for key in ["task:input", "flow:done:gate", "derived:ready"] {
2306 assert!(errors.iter().any(|error| error.contains(key)), "{errors:?}");
2307 }
2308 }
2309}