anip-runtime-utils 0.24.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2213 @@
1
+ """Helpers for consuming ANIP agent-consumability metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import json
7
+ from typing import Any
8
+
9
+ DEICTIC_REFERENCE_KEYS = {
10
+ "it",
11
+ "that",
12
+ "thatone",
13
+ "this",
14
+ "thisone",
15
+ "those",
16
+ "these",
17
+ "them",
18
+ "theone",
19
+ }
20
+
21
+ GENERIC_CATALOG_TOKENS = {
22
+ "account",
23
+ "accounts",
24
+ "add",
25
+ "after",
26
+ "and",
27
+ "before",
28
+ "candidate",
29
+ "candidates",
30
+ "cohort",
31
+ "company",
32
+ "companies",
33
+ "content",
34
+ "customer",
35
+ "customers",
36
+ "data",
37
+ "deal",
38
+ "deals",
39
+ "draft",
40
+ "drafting",
41
+ "email",
42
+ "enrich",
43
+ "enrichment",
44
+ "entity",
45
+ "entities",
46
+ "for",
47
+ "follow",
48
+ "followup",
49
+ "highest",
50
+ "in",
51
+ "lead",
52
+ "leads",
53
+ "list",
54
+ "linkedin",
55
+ "opportunity",
56
+ "opportunities",
57
+ "outreach",
58
+ "plan",
59
+ "prepare",
60
+ "preview",
61
+ "prioritize",
62
+ "prioritization",
63
+ "priority",
64
+ "record",
65
+ "records",
66
+ "recommendation",
67
+ "recommendations",
68
+ "reference",
69
+ "route",
70
+ "routed",
71
+ "routing",
72
+ "suggest",
73
+ "suggested",
74
+ "summary",
75
+ "show",
76
+ "target",
77
+ "targets",
78
+ "that",
79
+ "the",
80
+ "this",
81
+ "those",
82
+ "three",
83
+ "these",
84
+ "top",
85
+ "to",
86
+ "up",
87
+ "with",
88
+ }
89
+
90
+ TEMPORAL_REFERENCE_TOKENS = {"fiscal", "fy", "q1", "q2", "q3", "q4", "quarter", "quarters"}
91
+
92
+ CAPABILITY_SCORING_STOP_TOKENS = {
93
+ "after",
94
+ "and",
95
+ "before",
96
+ "for",
97
+ "in",
98
+ "of",
99
+ "that",
100
+ "the",
101
+ "these",
102
+ "this",
103
+ "those",
104
+ "to",
105
+ "with",
106
+ }
107
+
108
+ UNSUPPORTED_EFFECT_TERMS = {
109
+ "approval.execute": {"approve", "apply", "commit", "execute", "perform"},
110
+ "external_dispatch": {"deliver", "dispatch", "publish", "send", "ship"},
111
+ "raw_data_export": {"csv", "download", "dump", "export", "raw", "spreadsheet"},
112
+ "system.mutation": {"apply", "commit", "delete", "mutate", "update"},
113
+ }
114
+
115
+ APPROVAL_INTENT_TERMS = {
116
+ "approval",
117
+ "approve",
118
+ "draft",
119
+ "governed",
120
+ "message",
121
+ "plan",
122
+ "prepare",
123
+ "preview",
124
+ "reassign",
125
+ "reassignment",
126
+ "recommendation",
127
+ "recommendations",
128
+ "route",
129
+ "routing",
130
+ "task",
131
+ "tasks",
132
+ }
133
+
134
+ NEGATION_TERMS = {"avoid", "do", "dont", "do not", "exclude", "no", "not", "without"}
135
+
136
+ def semantic_text_key(value: Any) -> str:
137
+ return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
138
+
139
+
140
+ def text_tokens(value: Any) -> set[str]:
141
+ return {
142
+ token
143
+ for token in re.findall(r"[a-z0-9]+", str(value or "").lower().replace("_", " "))
144
+ if len(token) > 1
145
+ }
146
+
147
+
148
+ def ordered_text_tokens(value: Any) -> list[str]:
149
+ return [
150
+ token
151
+ for token in re.findall(r"[a-z0-9]+", str(value or "").lower().replace("_", " "))
152
+ if len(token) > 1
153
+ ]
154
+
155
+
156
+ def content_tokens(value: Any) -> set[str]:
157
+ return {
158
+ token
159
+ for token in text_tokens(value)
160
+ if token not in GENERIC_CATALOG_TOKENS
161
+ and token not in TEMPORAL_REFERENCE_TOKENS
162
+ and not re.fullmatch(r"(?:19|20)\d{2}", token)
163
+ }
164
+
165
+
166
+ def runtime_customization_for(capability_metadata: dict[str, Any] | None = None) -> dict[str, Any]:
167
+ if not isinstance(capability_metadata, dict):
168
+ return {}
169
+ direct = capability_metadata.get("runtime_customization")
170
+ if isinstance(direct, dict):
171
+ return direct
172
+ app_profile = capability_metadata.get("app_profile")
173
+ if isinstance(app_profile, dict) and isinstance(app_profile.get("runtime_customization"), dict):
174
+ return app_profile["runtime_customization"]
175
+ return {}
176
+
177
+
178
+ def _normalization_customization(customization: dict[str, Any] | None = None) -> dict[str, Any]:
179
+ if not isinstance(customization, dict):
180
+ return {}
181
+ value = customization.get("normalization")
182
+ return value if isinstance(value, dict) else {}
183
+
184
+
185
+ def _configured_deictic_terms(customization: dict[str, Any] | None = None) -> set[str]:
186
+ terms = set(DEICTIC_REFERENCE_KEYS)
187
+ raw_terms = _normalization_customization(customization).get("deictic_terms")
188
+ if isinstance(raw_terms, list):
189
+ terms.update(semantic_text_key(item) for item in raw_terms if str(item or "").strip())
190
+ return terms
191
+
192
+
193
+ def _configured_token_variant_rules(customization: dict[str, Any] | None = None) -> list[dict[str, Any]]:
194
+ raw_rules = _normalization_customization(customization).get("token_variant_rules")
195
+ return [rule for rule in raw_rules if isinstance(rule, dict)] if isinstance(raw_rules, list) else []
196
+
197
+
198
+ def _apply_token_variant_rules(tokens: set[str], customization: dict[str, Any] | None = None) -> set[str]:
199
+ variants = set(tokens)
200
+ for token in list(tokens):
201
+ for rule in _configured_token_variant_rules(customization):
202
+ suffix = str(rule.get("suffix") or "").strip().lower()
203
+ replacement = str(rule.get("replacement") or "")
204
+ try:
205
+ min_length = int(rule.get("min_length") or 0)
206
+ except (TypeError, ValueError):
207
+ min_length = 0
208
+ if not suffix or len(token) < min_length or not token.endswith(suffix):
209
+ continue
210
+ variants.add(f"{token[:-len(suffix)]}{replacement}")
211
+ return variants
212
+
213
+
214
+ def _apply_basic_inflection_variants(tokens: set[str]) -> set[str]:
215
+ variants = set(tokens)
216
+ for token in list(tokens):
217
+ if len(token) <= 3:
218
+ continue
219
+ if token.endswith("ies") and len(token) > 4:
220
+ variants.add(f"{token[:-3]}y")
221
+ if token.endswith("es") and len(token) > 4:
222
+ variants.add(token[:-2])
223
+ if token.endswith("s") and len(token) > 4:
224
+ variants.add(token[:-1])
225
+ else:
226
+ variants.add(f"{token}s")
227
+ return variants
228
+
229
+
230
+ def _capability_selection_customization(customization: dict[str, Any] | None = None) -> dict[str, Any]:
231
+ if not isinstance(customization, dict):
232
+ return {}
233
+ value = customization.get("capability_selection")
234
+ return value if isinstance(value, dict) else {}
235
+
236
+
237
+ def _runtime_capability_selection_for(capability_metadata: dict[str, Any]) -> dict[str, Any]:
238
+ return _capability_selection_customization(runtime_customization_for(capability_metadata))
239
+
240
+
241
+ def _list_of_dicts(value: Any) -> list[dict[str, Any]]:
242
+ return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
243
+
244
+
245
+ def _capability_id_for_metadata(capability_metadata: dict[str, Any]) -> str:
246
+ return str(capability_metadata.get("capability_id") or capability_metadata.get("id") or "").strip()
247
+
248
+
249
+ def _rule_applies_to_capability(rule: dict[str, Any], capability_id: str) -> bool:
250
+ rule_capability = str(rule.get("capability") or rule.get("capability_id") or "").strip()
251
+ return not rule_capability or not capability_id or rule_capability == capability_id
252
+
253
+
254
+ def runtime_business_language_rules_for(capability_metadata: dict[str, Any]) -> list[dict[str, Any]]:
255
+ capability_id = _capability_id_for_metadata(capability_metadata)
256
+ rules = _list_of_dicts(_runtime_capability_selection_for(capability_metadata).get("business_language_rules"))
257
+ return [rule for rule in rules if _rule_applies_to_capability(rule, capability_id)]
258
+
259
+
260
+ def runtime_selection_hints_for(metadata: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
261
+ hints: list[dict[str, Any]] = []
262
+ seen: set[str] = set()
263
+ for capability_metadata in metadata.values():
264
+ for hint in _list_of_dicts(_runtime_capability_selection_for(capability_metadata).get("selection_hints")):
265
+ key = repr(sorted(hint.items()))
266
+ if key in seen:
267
+ continue
268
+ seen.add(key)
269
+ hints.append(hint)
270
+ return hints
271
+
272
+
273
+ def _configured_float(
274
+ customization: dict[str, Any] | None,
275
+ key: str,
276
+ default: float,
277
+ ) -> float:
278
+ raw_value = _capability_selection_customization(customization).get(key)
279
+ try:
280
+ return float(raw_value)
281
+ except (TypeError, ValueError):
282
+ return default
283
+
284
+
285
+ def content_token_variants(value: Any, customization: dict[str, Any] | None = None) -> set[str]:
286
+ variants = set(content_tokens(value))
287
+ variants = _apply_basic_inflection_variants(variants)
288
+ return _apply_token_variant_rules(variants, customization)
289
+
290
+
291
+ def capability_score_tokens(value: Any) -> set[str]:
292
+ return {
293
+ token
294
+ for token in text_tokens(value)
295
+ if token not in CAPABILITY_SCORING_STOP_TOKENS
296
+ and not re.fullmatch(r"(?:19|20)\d{2}", token)
297
+ }
298
+
299
+
300
+ def capability_score_token_variants(value: Any, customization: dict[str, Any] | None = None) -> set[str]:
301
+ variants = set(capability_score_tokens(value))
302
+ variants = _apply_basic_inflection_variants(variants)
303
+ return _apply_token_variant_rules(variants, customization)
304
+
305
+
306
+ def exact_value_token_variants(value: Any, customization: dict[str, Any] | None = None) -> set[str]:
307
+ variants = text_tokens(value)
308
+ return _apply_token_variant_rules(variants, customization)
309
+
310
+
311
+ def temporal_tokens(value: Any) -> set[str]:
312
+ return text_tokens(value) & TEMPORAL_REFERENCE_TOKENS
313
+
314
+
315
+ def candidate_temporal_context_is_supported(candidate_text: str, source_text: str) -> bool:
316
+ candidate_temporal = temporal_tokens(candidate_text)
317
+ if not candidate_temporal:
318
+ return True
319
+ source_temporal = temporal_tokens(source_text)
320
+ if candidate_temporal & source_temporal:
321
+ return True
322
+ return quarter_label_from_text(source_text) is not None
323
+
324
+
325
+ def token_score(candidate_text: str, source_text: str) -> float:
326
+ candidate_tokens = text_tokens(candidate_text)
327
+ source_tokens = text_tokens(source_text)
328
+ if not candidate_tokens or not source_tokens:
329
+ return 0.0
330
+ overlap = candidate_tokens & source_tokens
331
+ if not overlap:
332
+ return 0.0
333
+ return len(overlap) / len(candidate_tokens)
334
+
335
+
336
+ def conversation_contains_value(conversation: str, value: Any) -> bool:
337
+ if not isinstance(value, str):
338
+ return True
339
+ raw_value = value.strip()
340
+ if not raw_value:
341
+ return False
342
+ if raw_value.lower() in conversation.lower():
343
+ return True
344
+ return semantic_text_key(raw_value) in semantic_text_key(conversation)
345
+
346
+
347
+ def is_deictic_reference(value: Any) -> bool:
348
+ return semantic_text_key(value) in DEICTIC_REFERENCE_KEYS
349
+
350
+
351
+ def contains_deictic_reference(value: Any, customization: dict[str, Any] | None = None) -> bool:
352
+ tokens = text_tokens(value)
353
+ deictic_terms = _configured_deictic_terms(customization)
354
+ return bool({semantic_text_key(token) for token in tokens} & deictic_terms) or semantic_text_key(value) in deictic_terms
355
+
356
+
357
+ def input_semantics_for(capability_metadata: dict[str, Any], input_name: str) -> dict[str, Any]:
358
+ items = capability_metadata.get("input_semantics")
359
+ if not isinstance(items, list):
360
+ return {}
361
+ for item in items:
362
+ if isinstance(item, dict) and str(item.get("input_name") or "") == input_name:
363
+ return item
364
+ return {}
365
+
366
+
367
+ def input_resolution_for(
368
+ capability_metadata: dict[str, Any],
369
+ input_name: str,
370
+ input_spec: dict[str, Any] | None = None,
371
+ ) -> dict[str, Any]:
372
+ """Return v0.24 input-resolution metadata from the strongest available source."""
373
+ if isinstance(input_spec, dict) and isinstance(input_spec.get("resolution"), dict):
374
+ return input_spec["resolution"]
375
+ semantics = input_semantics_for(capability_metadata, input_name)
376
+ if isinstance(semantics.get("resolution"), dict):
377
+ return semantics["resolution"]
378
+ for item in capability_metadata.get("input_specs") or []:
379
+ if isinstance(item, dict) and str(item.get("name") or item.get("input_name") or "") == input_name:
380
+ if isinstance(item.get("resolution"), dict):
381
+ return item["resolution"]
382
+ return {}
383
+
384
+
385
+ def input_resolution_mode_for(
386
+ capability_metadata: dict[str, Any],
387
+ input_name: str,
388
+ input_spec: dict[str, Any] | None = None,
389
+ ) -> str:
390
+ return str(input_resolution_for(capability_metadata, input_name, input_spec).get("mode") or "")
391
+
392
+
393
+ def input_meanings_for(capability_metadata: dict[str, Any], input_name: str) -> dict[str, Any]:
394
+ meanings = capability_metadata.get("app_profile", {}).get("input_meanings")
395
+ if not isinstance(meanings, dict):
396
+ return {}
397
+ value = meanings.get(input_name)
398
+ return value if isinstance(value, dict) else {}
399
+
400
+
401
+ def reference_catalog_for(capability_metadata: dict[str, Any], input_name: str) -> list[str]:
402
+ catalogs = capability_metadata.get("app_profile", {}).get("reference_catalogs")
403
+ if not isinstance(catalogs, dict):
404
+ return []
405
+ values = catalogs.get(input_name)
406
+ if not isinstance(values, list):
407
+ return []
408
+ return [str(item) for item in values if str(item or "").strip()]
409
+
410
+
411
+ def required_context_for(capability_metadata: dict[str, Any], input_name: str) -> dict[str, Any]:
412
+ items = capability_metadata.get("app_profile", {}).get("required_context") or capability_metadata.get("required_context")
413
+ if not isinstance(items, list):
414
+ return {}
415
+ for item in items:
416
+ if isinstance(item, dict) and str(item.get("input") or "") == input_name:
417
+ return item
418
+ return {}
419
+
420
+
421
+ def candidate_map_for_input(capability_metadata: dict[str, Any], input_name: str) -> dict[str, str]:
422
+ candidates = {
423
+ str(candidate): str(meaning)
424
+ for candidate, meaning in input_meanings_for(capability_metadata, input_name).items()
425
+ }
426
+ for catalog_value in reference_catalog_for(capability_metadata, input_name):
427
+ candidates.setdefault(catalog_value, catalog_value.replace("_", " "))
428
+ return candidates
429
+
430
+
431
+ def semantic_candidate_values_for_input(capability_metadata: dict[str, Any], input_name: str) -> dict[str, str]:
432
+ candidates: dict[str, str] = {}
433
+ for item in capability_metadata.get("input_semantics") or []:
434
+ if not isinstance(item, dict) or str(item.get("input_name") or "") != input_name:
435
+ continue
436
+ for allowed in item.get("allowed_values") or []:
437
+ if not isinstance(allowed, dict):
438
+ continue
439
+ value = str(allowed.get("value") or "").strip()
440
+ if value:
441
+ candidates.setdefault(value, str(allowed.get("meaning") or value))
442
+ app_profile = capability_metadata.get("app_profile")
443
+ if isinstance(app_profile, dict):
444
+ for item in app_profile.get("input_semantics") or []:
445
+ if not isinstance(item, dict) or str(item.get("input_name") or "") != input_name:
446
+ continue
447
+ for allowed in item.get("allowed_values") or []:
448
+ if not isinstance(allowed, dict):
449
+ continue
450
+ value = str(allowed.get("value") or "").strip()
451
+ if value:
452
+ candidates.setdefault(value, str(allowed.get("meaning") or value))
453
+ return candidates
454
+
455
+
456
+ def all_candidate_values_for_input(capability_metadata: dict[str, Any], input_spec: dict[str, Any]) -> dict[str, str]:
457
+ input_name = str(input_spec.get("name") or "")
458
+ candidates = candidate_map_for_input(capability_metadata, input_name)
459
+ candidates.update(semantic_candidate_values_for_input(capability_metadata, input_name))
460
+ if not candidates:
461
+ candidates.update({allowed: allowed.replace("_", " ") for allowed in declared_input_candidate_values(input_spec)})
462
+ return candidates
463
+
464
+
465
+ def value_matches_other_declared_input(
466
+ value: Any,
467
+ input_name: str,
468
+ capability_metadata: dict[str, Any],
469
+ ) -> bool:
470
+ if not isinstance(value, str) or not value.strip():
471
+ return False
472
+ value_key = semantic_text_key(value)
473
+ if not value_key:
474
+ return False
475
+ for input_spec in capability_metadata.get("input_specs") or []:
476
+ if not isinstance(input_spec, dict):
477
+ continue
478
+ other_name = str(input_spec.get("name") or "")
479
+ if not other_name or other_name == input_name:
480
+ continue
481
+ for candidate in all_candidate_values_for_input(capability_metadata, input_spec):
482
+ if semantic_text_key(candidate) == value_key:
483
+ return True
484
+ return False
485
+
486
+
487
+ def declared_input_candidate_values(input_spec: dict[str, Any]) -> list[str]:
488
+ allowed_values = [str(value) for value in input_spec.get("allowed_values") or [] if str(value or "").strip()]
489
+ if allowed_values:
490
+ return allowed_values
491
+
492
+ description = str(input_spec.get("description") or "")
493
+ default = input_spec.get("default")
494
+ if not isinstance(default, str) or not default.strip() or "," not in description:
495
+ return []
496
+
497
+ candidates: list[str] = []
498
+ for raw_part in re.split(r",|\bor\b", description):
499
+ candidate = raw_part.strip().strip(".;:")
500
+ if not candidate:
501
+ continue
502
+ if len(text_tokens(candidate)) > 3:
503
+ continue
504
+ if "_" in candidate or semantic_text_key(candidate) == semantic_text_key(default):
505
+ candidates.append(candidate.replace(" ", "_"))
506
+
507
+ deduped: list[str] = []
508
+ for candidate in candidates:
509
+ if candidate not in deduped:
510
+ deduped.append(candidate)
511
+ return deduped
512
+
513
+
514
+ def canonical_from_candidates(
515
+ value: str,
516
+ conversation: str,
517
+ candidates: dict[str, str],
518
+ customization: dict[str, Any] | None = None,
519
+ ) -> str | None:
520
+ raw_value = value.strip()
521
+ if not raw_value or not candidates:
522
+ return None
523
+ normalized_value = semantic_text_key(raw_value)
524
+ for candidate in candidates:
525
+ if normalized_value == semantic_text_key(candidate):
526
+ return candidate
527
+
528
+ source_text = f"{raw_value}\n{conversation}"
529
+ best_candidate = None
530
+ best_score = 0.0
531
+ second_best_score = 0.0
532
+ value_tokens = content_token_variants(raw_value, customization)
533
+ if not value_tokens:
534
+ return None
535
+ for candidate, meaning in candidates.items():
536
+ candidate_text = f"{candidate} {meaning}"
537
+ if not candidate_temporal_context_is_supported(candidate_text, source_text):
538
+ continue
539
+ candidate_tokens = content_token_variants(candidate_text, customization)
540
+ if not (value_tokens & candidate_tokens):
541
+ continue
542
+ value_score = (len(value_tokens & candidate_tokens) / len(value_tokens)) if value_tokens else 0.0
543
+ score = max(token_score(candidate, raw_value), token_score(candidate_text, source_text), value_score)
544
+ if score > best_score:
545
+ second_best_score = best_score
546
+ best_candidate = candidate
547
+ best_score = score
548
+ elif score > second_best_score:
549
+ second_best_score = score
550
+
551
+ if best_candidate is None:
552
+ return None
553
+ if best_score >= 0.5:
554
+ return best_candidate
555
+ # Short business phrases often provide one discriminating token ("sales",
556
+ # "SDR", "webinar"). Accept that only when it is unambiguous.
557
+ return best_candidate if best_score >= 0.3 and second_best_score == 0.0 else None
558
+
559
+
560
+ def conversation_supports_canonical_value(
561
+ conversation: str,
562
+ value: str,
563
+ candidates: dict[str, str],
564
+ customization: dict[str, Any] | None = None,
565
+ ) -> bool:
566
+ if contains_deictic_reference(value, customization):
567
+ return False
568
+ if conversation_contains_value(conversation, value):
569
+ return True
570
+ if contains_deictic_reference(conversation, customization):
571
+ return False
572
+ return canonical_from_candidates(conversation, conversation, candidates, customization) == value
573
+
574
+
575
+ def normalize_reference_value(
576
+ input_spec: dict[str, Any],
577
+ value: Any,
578
+ conversation: str,
579
+ capability_metadata: dict[str, Any],
580
+ ) -> Any:
581
+ if not isinstance(value, str) or not value.strip():
582
+ return value
583
+
584
+ input_name = str(input_spec.get("name") or "")
585
+ customization = runtime_customization_for(capability_metadata)
586
+ if contains_deictic_reference(value, customization):
587
+ return value
588
+
589
+ normalized = canonical_from_candidates(
590
+ value,
591
+ conversation,
592
+ candidate_map_for_input(capability_metadata, input_name),
593
+ customization,
594
+ )
595
+ return normalized if normalized is not None else value
596
+
597
+
598
+ def looks_like_quarter_input(input_spec: dict[str, Any]) -> bool:
599
+ name = str(input_spec.get("name") or "").strip().lower()
600
+ description = str(input_spec.get("description") or "").strip().lower()
601
+ return name in {"quarter", "fiscal_quarter"} or "quarter label" in description or "yyyy-q" in description
602
+
603
+
604
+ def quarter_label_from_text(text: str) -> str | None:
605
+ raw = str(text or "")
606
+ if not raw.strip():
607
+ return None
608
+ year_first = re.search(r"\b((?:19|20)\d{2})\s*[-_/ ]?\s*[Qq]([1-4])\b", raw)
609
+ if year_first:
610
+ return f"{year_first.group(1)}-Q{year_first.group(2)}"
611
+ quarter_first = re.search(r"\b[Qq]([1-4])\s*(?:FY\s*)?((?:19|20)\d{2})\b", raw)
612
+ if quarter_first:
613
+ return f"{quarter_first.group(2)}-Q{quarter_first.group(1)}"
614
+ return None
615
+
616
+
617
+ def normalize_declared_input_value(input_spec: dict[str, Any], value: Any, conversation: str) -> Any:
618
+ if not looks_like_quarter_input(input_spec) or not isinstance(value, str):
619
+ return value
620
+ return quarter_label_from_text(value) or quarter_label_from_text(conversation) or value
621
+
622
+
623
+ def is_supported_quarter_input_value(input_spec: dict[str, Any], value: Any, conversation: str) -> bool:
624
+ if not looks_like_quarter_input(input_spec) or not isinstance(value, str):
625
+ return True
626
+ return quarter_label_from_text(conversation) is not None
627
+
628
+
629
+ def infer_declared_input_value(
630
+ input_spec: dict[str, Any],
631
+ conversation: str,
632
+ capability_metadata: dict[str, Any],
633
+ ) -> Any:
634
+ if looks_like_quarter_input(input_spec):
635
+ explicit_quarter = quarter_label_from_text(conversation)
636
+ if explicit_quarter is not None:
637
+ return explicit_quarter
638
+ input_name = str(input_spec.get("name") or "")
639
+ customization = runtime_customization_for(capability_metadata)
640
+ candidates = all_candidate_values_for_input(capability_metadata, input_spec)
641
+ if looks_like_quarter_input(input_spec) and not candidates:
642
+ return None
643
+ if (
644
+ not looks_like_quarter_input(input_spec)
645
+ and contains_deictic_reference(conversation, customization)
646
+ and requires_declared_grounding(input_spec, capability_metadata)
647
+ ):
648
+ conversation_tokens = content_token_variants(conversation, customization)
649
+ candidates = {
650
+ candidate: meaning
651
+ for candidate, meaning in candidates.items()
652
+ if content_token_variants(candidate, customization) & conversation_tokens
653
+ }
654
+ normalized = canonical_from_candidates(conversation, conversation, candidates, customization)
655
+ if normalized is not None:
656
+ return normalized
657
+
658
+ # For reviewed enum/allowed values, the value identifiers themselves are
659
+ # part of the contract. Allow a unique exact-token match even when the token
660
+ # is too generic for free-form catalog matching, e.g. account_risk from
661
+ # "account reassignment".
662
+ conversation_tokens = exact_value_token_variants(conversation, customization)
663
+ exact_matches = [
664
+ candidate
665
+ for candidate in candidates
666
+ if exact_value_token_variants(candidate, customization) & conversation_tokens
667
+ and candidate_temporal_context_is_supported(f"{candidate} {candidates.get(candidate, '')}", conversation)
668
+ ]
669
+ if len(exact_matches) == 1:
670
+ return exact_matches[0]
671
+ input_name = str(input_spec.get("name") or "")
672
+ has_reviewed_candidates = bool(
673
+ input_meanings_for(capability_metadata, input_name)
674
+ or reference_catalog_for(capability_metadata, input_name)
675
+ )
676
+ return None
677
+
678
+
679
+ def requires_declared_grounding(input_spec: dict[str, Any], capability_metadata: dict[str, Any]) -> bool:
680
+ input_name = str(input_spec.get("name") or "")
681
+ input_description = str(input_spec.get("description") or "")
682
+ if not input_name or input_spec.get("default") is not None:
683
+ return False
684
+ resolution_mode = input_resolution_mode_for(capability_metadata, input_name, input_spec)
685
+ if resolution_mode:
686
+ return resolution_mode in {
687
+ "closed_values",
688
+ "backend_resolved",
689
+ "app_selected",
690
+ "actor_policy",
691
+ "actor_policy_or_explicit",
692
+ "explicit_only",
693
+ "clarify",
694
+ }
695
+ input_key = semantic_text_key(f"{input_name} {input_description}")
696
+ semantics = input_semantics_for(capability_metadata, input_name)
697
+ required_context = required_context_for(capability_metadata, input_name)
698
+ input_meanings = input_meanings_for(capability_metadata, input_name)
699
+ reference_catalog = reference_catalog_for(capability_metadata, input_name)
700
+ missing_behavior = str(required_context.get("missing_behavior") or "")
701
+ semantic_type = str(semantics.get("semantic_type") or "")
702
+ if input_meanings or reference_catalog:
703
+ return True
704
+ if any(marker in input_key for marker in ("reference", "target", "entity", "subject")) or input_name.endswith("_ref"):
705
+ return True
706
+ if semantic_type.endswith("_reference") or semantic_type in {"entity_reference", "business_context"}:
707
+ return True
708
+ return missing_behavior in {"clarify", "clarify_or_app_select"}
709
+
710
+
711
+ def allows_open_text_reference_inference(input_spec: dict[str, Any], capability_metadata: dict[str, Any]) -> bool:
712
+ """Allow open-text inference only for fields that are explicitly reference-like."""
713
+ input_name = str(input_spec.get("name") or "")
714
+ resolution_mode = input_resolution_mode_for(capability_metadata, input_name, input_spec)
715
+ if resolution_mode:
716
+ return resolution_mode == "backend_resolved"
717
+ input_description = str(input_spec.get("description") or "")
718
+ input_key = semantic_text_key(f"{input_name} {input_description}")
719
+ semantics = input_semantics_for(capability_metadata, input_name)
720
+ semantic_type = str(semantics.get("semantic_type") or "")
721
+ if any(marker in input_key for marker in ("reference", "target", "entity", "subject")) or input_name.endswith("_ref"):
722
+ return True
723
+ return (semantic_type.endswith("_reference") and semantic_type != "scope_reference") or semantic_type == "entity_reference"
724
+
725
+
726
+ def is_ungrounded_declared_context(
727
+ input_spec: dict[str, Any],
728
+ value: Any,
729
+ conversation: str,
730
+ capability_metadata: dict[str, Any],
731
+ ) -> bool:
732
+ if not isinstance(value, str) or not requires_declared_grounding(input_spec, capability_metadata):
733
+ return False
734
+ customization = runtime_customization_for(capability_metadata)
735
+ if contains_deictic_reference(value, customization):
736
+ return True
737
+ input_name = str(input_spec.get("name") or "")
738
+ candidates = candidate_map_for_input(capability_metadata, input_name)
739
+ if not candidates:
740
+ if not allows_open_text_reference_inference(input_spec, capability_metadata):
741
+ return True
742
+ if value_matches_other_declared_input(value, input_name, capability_metadata):
743
+ return True
744
+ # Open-ended entity/reference inputs are validated by the service or
745
+ # backend. The app should pass concrete names through instead of
746
+ # requiring Studio to enumerate every possible business entity.
747
+ return not looks_like_concrete_reference_value(value)
748
+ if conversation_supports_canonical_value(conversation, value, candidates, customization):
749
+ return False
750
+ return True
751
+
752
+
753
+ def looks_like_concrete_reference_value(value: Any) -> bool:
754
+ if not isinstance(value, str):
755
+ return True
756
+ raw_value = value.strip()
757
+ if not raw_value:
758
+ return False
759
+ lowered = raw_value.lower()
760
+ vague_phrases = {
761
+ "best customer",
762
+ "top account",
763
+ "top customer",
764
+ "best account",
765
+ "selected account",
766
+ "recommended account",
767
+ "highest priority account",
768
+ "our best customer",
769
+ "our top account",
770
+ }
771
+ if lowered in vague_phrases:
772
+ return False
773
+ if contains_deictic_reference(raw_value):
774
+ return False
775
+ tokens = re.findall(r"[A-Za-z0-9]+", raw_value)
776
+ if not tokens:
777
+ return False
778
+ non_entity_tokens = {
779
+ "draft",
780
+ "east",
781
+ "find",
782
+ "for",
783
+ "north",
784
+ "prioritize",
785
+ "q1",
786
+ "q2",
787
+ "q3",
788
+ "q4",
789
+ "review",
790
+ "show",
791
+ "south",
792
+ "use",
793
+ "west",
794
+ }
795
+ if all(token.lower() in non_entity_tokens or re.fullmatch(r"(?:19|20)\d{2}", token) for token in tokens):
796
+ return False
797
+ if any(any(character.isupper() for character in token) for token in tokens):
798
+ return True
799
+ # Stable business identifiers such as account_123 or acme-corp are concrete
800
+ # even when they arrive lower-cased.
801
+ return bool(re.search(r"[_-]|\d", raw_value)) and len(content_tokens(raw_value)) >= 1
802
+
803
+
804
+ def concrete_reference_value_from_text(text: str) -> str | None:
805
+ stop_terms = {
806
+ "A",
807
+ "An",
808
+ "And",
809
+ "CRM",
810
+ "CSV",
811
+ "East",
812
+ "Find",
813
+ "For",
814
+ "GTM",
815
+ "North",
816
+ "Q1",
817
+ "Q2",
818
+ "Q3",
819
+ "Q4",
820
+ "Route",
821
+ "Show",
822
+ "South",
823
+ "West",
824
+ }
825
+ for match in re.finditer(r"\b[A-Z][A-Za-z0-9]*(?:\s+[A-Z][A-Za-z0-9]*){0,3}\b", str(text or "")):
826
+ candidate = match.group(0).strip()
827
+ if not candidate or candidate in stop_terms:
828
+ continue
829
+ if re.fullmatch(r"Q[1-4]|FY\d{2,4}|\d{4}", candidate):
830
+ continue
831
+ if looks_like_concrete_reference_value(candidate):
832
+ return candidate
833
+ return None
834
+
835
+
836
+ def capability_produces(capability_metadata: dict[str, Any]) -> set[str]:
837
+ effects = capability_metadata.get("business_effects")
838
+ if not isinstance(effects, dict):
839
+ return set()
840
+ values = effects.get("produces")
841
+ return {str(item) for item in values} if isinstance(values, list) else set()
842
+
843
+
844
+ def capability_does_not_produce(capability_metadata: dict[str, Any]) -> set[str]:
845
+ effects = capability_metadata.get("business_effects")
846
+ if not isinstance(effects, dict):
847
+ return set()
848
+ values = effects.get("does_not_produce")
849
+ blocked = {str(item) for item in values} if isinstance(values, list) else set()
850
+ app_boundaries = capability_metadata.get("app_profile", {}).get("app_boundaries")
851
+ if isinstance(app_boundaries, dict) and isinstance(app_boundaries.get("unsupported_effects"), list):
852
+ blocked.update(str(item) for item in app_boundaries["unsupported_effects"])
853
+ return blocked
854
+
855
+
856
+ def effective_business_effects(*sources: dict[str, Any]) -> dict[str, Any]:
857
+ effects: dict[str, Any] = {}
858
+ for source in sources:
859
+ candidate = source.get("business_effects")
860
+ if isinstance(candidate, dict):
861
+ effects = dict(candidate)
862
+ break
863
+ if any(isinstance(source.get("grant_policy"), dict) for source in sources):
864
+ produces = {str(item) for item in effects.get("produces") or []}
865
+ does_not_produce = {str(item) for item in effects.get("does_not_produce") or []}
866
+ produces.discard("data.read")
867
+ produces.update({"approval.request", "system.preview_mutation"})
868
+ does_not_produce.add("approval.execute")
869
+ effects["produces"] = sorted(produces)
870
+ effects["does_not_produce"] = sorted(does_not_produce)
871
+ return effects
872
+
873
+
874
+ def metadata_with_manifest_controls(profile_metadata: dict[str, Any], manifest_capability: dict[str, Any]) -> dict[str, Any]:
875
+ if not isinstance(manifest_capability.get("grant_policy"), dict):
876
+ return profile_metadata
877
+ metadata = dict(profile_metadata)
878
+ approval = dict(metadata.get("approval")) if isinstance(metadata.get("approval"), dict) else {}
879
+ approval.setdefault("required", True)
880
+ approval.setdefault("grant_types", manifest_capability["grant_policy"].get("allowed_grant_types") or [])
881
+ approval.setdefault("approval_effect", "approval.request")
882
+ metadata["approval"] = approval
883
+ app_boundaries = dict(metadata.get("app_boundaries")) if isinstance(metadata.get("app_boundaries"), dict) else {}
884
+ app_boundaries.setdefault(
885
+ "guidance",
886
+ "This capability is approval-governed. Invoke it to produce the service-owned preview/request; do not execute the governed action in app code.",
887
+ )
888
+ metadata["app_boundaries"] = app_boundaries
889
+ return metadata
890
+
891
+
892
+ def compact_agent_json(value: Any) -> str:
893
+ """Render compact deterministic JSON for prompt-safe metadata fragments."""
894
+ return json.dumps(value, sort_keys=True, separators=(",", ":"))
895
+
896
+
897
+ def render_agent_input_spec(item: dict[str, Any]) -> str:
898
+ name = str(item.get("name") or "").strip()
899
+ if not name:
900
+ return ""
901
+ fragments = [name]
902
+ description = str(item.get("description") or "").strip()
903
+ input_type = str(item.get("type") or "").strip()
904
+ if input_type:
905
+ fragments.append(f"type={input_type}")
906
+ if item.get("required") is not None:
907
+ fragments.append(f"required={bool(item.get('required'))}")
908
+ if item.get("default") is not None:
909
+ fragments.append(f"default={item.get('default')}")
910
+ allowed_values = item.get("allowed_values")
911
+ if isinstance(allowed_values, list) and allowed_values:
912
+ fragments.append(f"allowed={','.join(str(value) for value in allowed_values)}")
913
+ if description:
914
+ fragments.append(f"description={description}")
915
+ return f"{name}[{' | '.join(fragments[1:])}]" if len(fragments) > 1 else name
916
+
917
+
918
+ def render_agent_business_effects(effects: dict[str, Any]) -> str:
919
+ if not effects:
920
+ return ""
921
+ fragments: list[str] = []
922
+ for key in ("produces", "does_not_produce"):
923
+ values = effects.get(key)
924
+ if isinstance(values, list) and values:
925
+ fragments.append(f"{key}={','.join(str(value) for value in values)}")
926
+ return " | business_effects=" + "; ".join(fragments) if fragments else ""
927
+
928
+
929
+ def _render_agent_profile_fragment(metadata: dict[str, Any], *, keys: tuple[str, ...]) -> str:
930
+ fragments: list[str] = []
931
+ framing = str(metadata.get("capability_framing") or "").strip()
932
+ if framing:
933
+ fragments.append(f"capability_framing={framing}")
934
+ for key in keys:
935
+ value = metadata.get(key)
936
+ if isinstance(value, (dict, list)) and value:
937
+ fragments.append(f"{key}={compact_agent_json(value)}")
938
+ return " | app_profile=" + " ; ".join(fragments) if fragments else ""
939
+
940
+
941
+ def render_agent_routing_metadata(metadata: dict[str, Any]) -> str:
942
+ """Render metadata useful for capability selection without dumping full app profile state."""
943
+ return _render_agent_profile_fragment(
944
+ metadata,
945
+ keys=(
946
+ "app_boundaries",
947
+ "approval",
948
+ "required_context",
949
+ "app_glue",
950
+ "derived_target_owner",
951
+ ),
952
+ )
953
+
954
+
955
+ def render_agent_detail_metadata(metadata: dict[str, Any]) -> str:
956
+ """Render full per-capability planning metadata for optional second-stage prompts/debugging."""
957
+ return _render_agent_profile_fragment(
958
+ metadata,
959
+ keys=(
960
+ "input_meanings",
961
+ "input_semantics",
962
+ "reference_catalogs",
963
+ "result_display",
964
+ "app_boundaries",
965
+ "approval",
966
+ "required_context",
967
+ "app_glue",
968
+ "derived_target_owner",
969
+ "intent_rules",
970
+ "business_language_rules",
971
+ ),
972
+ )
973
+
974
+
975
+ def render_compact_agent_input_summary(input_specs: list[Any]) -> str:
976
+ """Render input specs for compact routing prompts.
977
+
978
+ This is intentionally smaller than `render_agent_input_spec`: compact
979
+ profile prompts need enough structure for routing and obvious parameter
980
+ binding, while full contract validation still happens against the complete
981
+ metadata after selection.
982
+ """
983
+ rendered: list[str] = []
984
+ for raw_spec in input_specs:
985
+ if not isinstance(raw_spec, dict):
986
+ continue
987
+ name = str(raw_spec.get("name") or "").strip()
988
+ if not name:
989
+ continue
990
+ marker = "req" if raw_spec.get("required") is True else "opt"
991
+ allowed = raw_spec.get("allowed_values")
992
+ allowed_text = ""
993
+ if isinstance(allowed, list) and allowed:
994
+ allowed_text = f"={ '/'.join(str(item) for item in allowed[:8]) }"
995
+ rendered.append(f"{name}({marker}{allowed_text})")
996
+ return ", ".join(rendered) or "none"
997
+
998
+
999
+ def _metadata_effect_values(metadata: dict[str, Any], key: str) -> list[str]:
1000
+ effects = metadata.get("business_effects")
1001
+ values = effects.get(key) if isinstance(effects, dict) else None
1002
+ return [str(item) for item in values if str(item).strip()] if isinstance(values, list) else []
1003
+
1004
+
1005
+ def render_compact_agent_capability_line(capability_id: str, metadata: dict[str, Any]) -> str:
1006
+ """Render one compact capability candidate line for model routing."""
1007
+ produces = _metadata_effect_values(metadata, "produces")
1008
+ forbidden = _metadata_effect_values(metadata, "does_not_produce")
1009
+ grant_policy = metadata.get("grant_policy")
1010
+ approval = " approval" if isinstance(grant_policy, dict) and grant_policy else ""
1011
+ input_specs = metadata.get("input_specs") if isinstance(metadata.get("input_specs"), list) else []
1012
+ return (
1013
+ f"- {capability_id}: {metadata.get('description') or 'No description provided.'} "
1014
+ f"| service={metadata.get('service_name') or 'unknown'} "
1015
+ f"| inputs={render_compact_agent_input_summary(input_specs)} "
1016
+ f"| side_effect={metadata.get('side_effect') or 'unknown'}{approval} "
1017
+ f"| produces={','.join(produces) or 'none'} "
1018
+ f"| forbids={','.join(forbidden) or 'none'}"
1019
+ )
1020
+
1021
+
1022
+ def build_compact_agent_capability_brief(
1023
+ conversation: str,
1024
+ metadata: dict[str, dict[str, Any]],
1025
+ *,
1026
+ top_n: int = 10,
1027
+ ) -> tuple[str, dict[str, Any]]:
1028
+ """Build a compact top-N routing brief from full runtime metadata.
1029
+
1030
+ The returned brief is an optimization artifact only. Callers must retain the
1031
+ full metadata for normalization, invocation, permission, approval, denial,
1032
+ recovery, and audit behavior.
1033
+ """
1034
+ bounded_top_n = max(1, int(top_n or 1))
1035
+ scored = sorted(
1036
+ (
1037
+ (
1038
+ compact_capability_match_score(conversation, capability_id, capability_metadata),
1039
+ capability_id,
1040
+ capability_metadata,
1041
+ )
1042
+ for capability_id, capability_metadata in metadata.items()
1043
+ ),
1044
+ key=lambda item: (-item[0], item[1]),
1045
+ )
1046
+ selected = scored[: min(bounded_top_n, len(scored))]
1047
+ lines = [
1048
+ "Compact ANIP capability candidates selected by local retrieval.",
1049
+ "The model must choose only from these candidate capability IDs.",
1050
+ ]
1051
+ lines.extend(render_compact_agent_capability_line(capability_id, capability_metadata) for _, capability_id, capability_metadata in selected)
1052
+ brief = "\n".join(lines)
1053
+ return brief, {
1054
+ "compact_catalog": True,
1055
+ "compact_top_n": len(selected),
1056
+ "compact_candidate_ids": [capability_id for _, capability_id, _ in selected],
1057
+ "compact_candidate_scores": {capability_id: round(score, 4) for score, capability_id, _ in selected},
1058
+ "compact_brief_chars": len(brief),
1059
+ }
1060
+
1061
+
1062
+ def _capability_map(raw_value: Any) -> dict[str, dict[str, Any]]:
1063
+ if isinstance(raw_value, dict):
1064
+ return {str(key): value for key, value in raw_value.items() if isinstance(value, dict)}
1065
+ if isinstance(raw_value, list):
1066
+ return {
1067
+ str(item.get("name")): item
1068
+ for item in raw_value
1069
+ if isinstance(item, dict) and str(item.get("name") or "").strip()
1070
+ }
1071
+ return {}
1072
+
1073
+
1074
+ def build_agent_capability_catalog(
1075
+ service_payloads: list[dict[str, Any]],
1076
+ profile_capability_metadata: dict[str, dict[str, Any]] | None = None,
1077
+ *,
1078
+ allow_duplicate_service_urls: bool = False,
1079
+ ) -> dict[str, Any]:
1080
+ """Build compact prompt briefs and full runtime metadata from ANIP discovery payloads.
1081
+
1082
+ Network discovery remains outside this helper. Callers pass already-fetched
1083
+ `.well-known/anip` and manifest payloads, so the shared runtime utility stays
1084
+ dependency-free and usable across app runtimes.
1085
+ """
1086
+ profile_capability_metadata = profile_capability_metadata or {}
1087
+ metadata: dict[str, dict[str, Any]] = {}
1088
+ routing_lines: list[str] = []
1089
+ detail_lines: list[str] = []
1090
+ details_by_capability: dict[str, str] = {}
1091
+ services: list[dict[str, Any]] = []
1092
+ seen_urls: dict[str, str] = {}
1093
+
1094
+ for index, service in enumerate(service_payloads):
1095
+ service_name = str(service.get("name") or service.get("service") or f"service-{index + 1}").strip()
1096
+ service_url = str(service.get("url") or "").strip().rstrip("/")
1097
+ if not service_name or not service_url:
1098
+ continue
1099
+ if not allow_duplicate_service_urls and service_url in seen_urls:
1100
+ raise ValueError(
1101
+ "Duplicate ANIP service URL discovered "
1102
+ f"({service_url!r} for {seen_urls[service_url]!r} and {service_name!r}). "
1103
+ "Use distinct service endpoints or explicitly allow duplicate URLs."
1104
+ )
1105
+ seen_urls[service_url] = service_name
1106
+ normalized_service = {"name": service_name, "url": service_url}
1107
+ for field in ("approval_list_path", "approval_approve_path_template"):
1108
+ value = str(service.get(field) or "").strip()
1109
+ if value:
1110
+ normalized_service[field] = value
1111
+ services.append(normalized_service)
1112
+
1113
+ discovery_payload = service.get("discovery")
1114
+ discovery = discovery_payload.get("anip_discovery", discovery_payload) if isinstance(discovery_payload, dict) else {}
1115
+ manifest = service.get("manifest") if isinstance(service.get("manifest"), dict) else {}
1116
+ discovery_caps = _capability_map(discovery.get("capabilities") if isinstance(discovery, dict) else None)
1117
+ manifest_caps = _capability_map(manifest.get("capabilities"))
1118
+
1119
+ for capability_id in sorted(set(discovery_caps) | set(manifest_caps)):
1120
+ discovery_cap = discovery_caps.get(capability_id, {})
1121
+ manifest_cap = manifest_caps.get(capability_id, {})
1122
+ input_specs = [
1123
+ item
1124
+ for item in manifest_cap.get("inputs", [])
1125
+ if isinstance(item, dict) and item.get("name")
1126
+ ]
1127
+ input_names = [str(item.get("name")) for item in input_specs]
1128
+ rendered_inputs = [render_agent_input_spec(item) for item in input_specs]
1129
+ side_effect = discovery_cap.get("side_effect") or manifest_cap.get("side_effect", {}).get("type") or "unknown"
1130
+ minimum_scope = discovery_cap.get("minimum_scope") or manifest_cap.get("minimum_scope") or []
1131
+ description = str(discovery_cap.get("description") or manifest_cap.get("description") or "").strip()
1132
+ profile_metadata = profile_capability_metadata.get(capability_id)
1133
+ if not isinstance(profile_metadata, dict):
1134
+ profile_metadata = {}
1135
+ profile_metadata = metadata_with_manifest_controls(profile_metadata, manifest_cap)
1136
+ business_effects = effective_business_effects(discovery_cap, manifest_cap, profile_metadata)
1137
+ app_profile = {
1138
+ key: value
1139
+ for key, value in profile_metadata.items()
1140
+ if key
1141
+ in {
1142
+ "capability_framing",
1143
+ "input_meanings",
1144
+ "input_semantics",
1145
+ "reference_catalogs",
1146
+ "result_display",
1147
+ "app_boundaries",
1148
+ "approval",
1149
+ "required_context",
1150
+ "app_glue",
1151
+ "derived_target_owner",
1152
+ "intent_rules",
1153
+ "business_language_rules",
1154
+ }
1155
+ }
1156
+ metadata[capability_id] = {
1157
+ "capability_id": capability_id,
1158
+ "description": description,
1159
+ "minimum_scope": minimum_scope,
1160
+ "inputs": input_names,
1161
+ "input_specs": input_specs,
1162
+ "side_effect": side_effect,
1163
+ "grant_policy": manifest_cap.get("grant_policy") if isinstance(manifest_cap.get("grant_policy"), dict) else None,
1164
+ "business_effects": business_effects,
1165
+ "input_semantics": profile_metadata.get("input_semantics") if isinstance(profile_metadata.get("input_semantics"), list) else [],
1166
+ "app_profile": app_profile,
1167
+ "runtime_customization": profile_metadata.get("runtime_customization")
1168
+ if isinstance(profile_metadata.get("runtime_customization"), dict)
1169
+ else None,
1170
+ "service_name": service_name,
1171
+ "service_url": service_url,
1172
+ }
1173
+ base_line = (
1174
+ f"- {capability_id}: {description or 'No description provided.'} "
1175
+ f"| service={service_name} "
1176
+ f"| inputs={', '.join(rendered_inputs) or 'none'} "
1177
+ f"| minimum_scope={', '.join(str(scope) for scope in minimum_scope) or 'none'} "
1178
+ f"| side_effect={side_effect}"
1179
+ f"{render_agent_business_effects(business_effects)}"
1180
+ )
1181
+ routing_line = f"{base_line}{render_agent_routing_metadata(profile_metadata)}"
1182
+ detail_line = f"{base_line}{render_agent_detail_metadata(profile_metadata)}"
1183
+ routing_lines.append(routing_line)
1184
+ detail_lines.append(detail_line)
1185
+ details_by_capability[capability_id] = detail_line
1186
+
1187
+ if not metadata:
1188
+ raise ValueError("No ANIP capabilities were discovered from configured services")
1189
+
1190
+ routing_brief = "\n".join(routing_lines)
1191
+ detail_brief = "\n".join(detail_lines)
1192
+ return {
1193
+ "routing_brief": routing_brief,
1194
+ "detail_brief": detail_brief,
1195
+ "details_by_capability": details_by_capability,
1196
+ "metadata": metadata,
1197
+ "services": services,
1198
+ "stats": {
1199
+ "service_count": len(services),
1200
+ "capability_count": len(metadata),
1201
+ "routing_brief_chars": len(routing_brief),
1202
+ "detail_brief_chars": len(detail_brief),
1203
+ },
1204
+ }
1205
+
1206
+
1207
+ def selected_agent_capability_detail(catalog: dict[str, Any], capability_id: str) -> str:
1208
+ details = catalog.get("details_by_capability")
1209
+ if isinstance(details, dict):
1210
+ value = details.get(capability_id)
1211
+ if isinstance(value, str):
1212
+ return value
1213
+ return ""
1214
+
1215
+
1216
+ def app_boundaries_for(capability_metadata: dict[str, Any]) -> dict[str, Any]:
1217
+ app_profile = capability_metadata.get("app_profile")
1218
+ profile_boundaries = app_profile.get("app_boundaries") if isinstance(app_profile, dict) else None
1219
+ if isinstance(profile_boundaries, dict):
1220
+ return profile_boundaries
1221
+ direct_boundaries = capability_metadata.get("app_boundaries")
1222
+ return direct_boundaries if isinstance(direct_boundaries, dict) else {}
1223
+
1224
+
1225
+ def business_language_rules_for(capability_metadata: dict[str, Any]) -> list[dict[str, Any]]:
1226
+ rules: list[dict[str, Any]] = []
1227
+ app_profile = capability_metadata.get("app_profile")
1228
+ profile_rules = app_profile.get("business_language_rules") if isinstance(app_profile, dict) else None
1229
+ if isinstance(profile_rules, list):
1230
+ rules.extend(rule for rule in profile_rules if isinstance(rule, dict))
1231
+ direct_rules = capability_metadata.get("business_language_rules")
1232
+ if isinstance(direct_rules, list):
1233
+ rules.extend(rule for rule in direct_rules if isinstance(rule, dict))
1234
+ rules.extend(runtime_business_language_rules_for(capability_metadata))
1235
+ deduped: list[dict[str, Any]] = []
1236
+ seen: set[str] = set()
1237
+ for rule in rules:
1238
+ key = str(rule.get("id") or repr(sorted(rule.items())))
1239
+ if key in seen:
1240
+ continue
1241
+ seen.add(key)
1242
+ deduped.append(rule)
1243
+ return deduped
1244
+
1245
+
1246
+ def _condition_terms(value: Any) -> list[str]:
1247
+ if not isinstance(value, list):
1248
+ return []
1249
+ return [str(item).strip().lower() for item in value if str(item or "").strip()]
1250
+
1251
+
1252
+ def business_language_rule_matches(conversation: str, rule: dict[str, Any]) -> bool:
1253
+ condition = rule.get("applies_when")
1254
+ if not isinstance(condition, dict):
1255
+ return False
1256
+ lowered = str(conversation or "").lower()
1257
+ all_terms = _condition_terms(condition.get("all_terms"))
1258
+ any_terms = _condition_terms(condition.get("any_terms"))
1259
+ exclude_terms = _condition_terms(condition.get("exclude_terms"))
1260
+ if all_terms and not all(term in lowered for term in all_terms):
1261
+ return False
1262
+ if any_terms and not any(term in lowered for term in any_terms):
1263
+ return False
1264
+ if exclude_terms and any(term in lowered for term in exclude_terms):
1265
+ return False
1266
+ return bool(all_terms or any_terms)
1267
+
1268
+
1269
+ def matching_business_language_rules(
1270
+ conversation: str,
1271
+ capability_metadata: dict[str, Any],
1272
+ ) -> list[dict[str, Any]]:
1273
+ return [
1274
+ rule
1275
+ for rule in business_language_rules_for(capability_metadata)
1276
+ if business_language_rule_matches(conversation, rule)
1277
+ ]
1278
+
1279
+
1280
+ def rule_suppressed_effects(rule: dict[str, Any]) -> set[str]:
1281
+ values = rule.get("suppress_unsupported_effects")
1282
+ if isinstance(values, list):
1283
+ return {str(value) for value in values if str(value or "").strip()}
1284
+ if str(rule.get("agent_action") or "") in {"treat_as_supported", "treat_as_purpose"}:
1285
+ return {"*"}
1286
+ return set()
1287
+
1288
+
1289
+ def supported_by_business_language_rule(
1290
+ conversation: str,
1291
+ capability_metadata: dict[str, Any],
1292
+ unsupported_effects: set[str] | None = None,
1293
+ ) -> bool:
1294
+ effects = unsupported_effects or set()
1295
+ for rule in matching_business_language_rules(conversation, capability_metadata):
1296
+ suppressed = rule_suppressed_effects(rule)
1297
+ if "*" in suppressed:
1298
+ return True
1299
+ if effects and effects <= suppressed:
1300
+ return True
1301
+ return False
1302
+
1303
+
1304
+ def conditional_approval_boundary(capability_metadata: dict[str, Any]) -> dict[str, Any]:
1305
+ boundary = app_boundaries_for(capability_metadata).get("conditional_approval_boundary")
1306
+ return boundary if isinstance(boundary, dict) else {}
1307
+
1308
+
1309
+ def conditional_approval_missing_inputs(capability_metadata: dict[str, Any]) -> set[str]:
1310
+ when_missing = conditional_approval_boundary(capability_metadata).get("when_missing")
1311
+ return {str(item) for item in when_missing if str(item or "").strip()} if isinstance(when_missing, list) else set()
1312
+
1313
+
1314
+ def conditional_approval_produces(capability_metadata: dict[str, Any]) -> set[str]:
1315
+ produces = conditional_approval_boundary(capability_metadata).get("produces")
1316
+ return {str(item) for item in produces if str(item or "").strip()} if isinstance(produces, list) else set()
1317
+
1318
+
1319
+ def has_conditional_approval_boundary(capability_metadata: dict[str, Any]) -> bool:
1320
+ return bool(
1321
+ conditional_approval_missing_inputs(capability_metadata)
1322
+ and ({"approval.request", "system.preview_mutation"} & conditional_approval_produces(capability_metadata))
1323
+ )
1324
+
1325
+
1326
+ def is_conditional_approval_boundary_active(
1327
+ capability_metadata: dict[str, Any],
1328
+ parameter_values: dict[str, Any] | None,
1329
+ ) -> bool:
1330
+ missing_inputs = conditional_approval_missing_inputs(capability_metadata)
1331
+ if not missing_inputs:
1332
+ return False
1333
+ values = parameter_values if isinstance(parameter_values, dict) else {}
1334
+ for input_name in missing_inputs:
1335
+ value = values.get(input_name)
1336
+ if value is None or value == "" or value == []:
1337
+ return True
1338
+ return False
1339
+
1340
+
1341
+ def requested_unsupported_effects(conversation: str, capability_metadata: dict[str, Any]) -> set[str]:
1342
+ tokens = text_tokens(conversation)
1343
+ ordered_tokens = ordered_text_tokens(conversation)
1344
+ blocked = capability_does_not_produce(capability_metadata)
1345
+ produced = capability_produces(capability_metadata)
1346
+ requested: set[str] = set()
1347
+ if "approval.execute" in blocked and requests_approval_bypass(conversation):
1348
+ requested.add("approval.execute")
1349
+ unsupported_terms = app_boundaries_for(capability_metadata).get("unsupported_terms")
1350
+ if isinstance(unsupported_terms, dict):
1351
+ lowered = str(conversation or "").lower()
1352
+ for effect, terms in unsupported_terms.items():
1353
+ if not isinstance(terms, list):
1354
+ continue
1355
+ for term in terms:
1356
+ term_text = str(term or "").strip().lower()
1357
+ if term_text and term_text in lowered:
1358
+ requested.add(str(effect))
1359
+ matching_rules = matching_business_language_rules(conversation, capability_metadata)
1360
+ for effect, terms in UNSUPPORTED_EFFECT_TERMS.items():
1361
+ matched_terms = tokens & terms
1362
+ if not matched_terms:
1363
+ continue
1364
+ if all(_term_is_negated(ordered_tokens, term) for term in matched_terms):
1365
+ continue
1366
+ if (
1367
+ effect in blocked
1368
+ or (effect == "raw_data_export" and "raw_data_export" not in produced)
1369
+ or (effect == "external_dispatch" and "content.draft" in produced)
1370
+ ):
1371
+ requested.add(effect)
1372
+ for rule in matching_rules:
1373
+ suppressed = rule_suppressed_effects(rule)
1374
+ if "*" in suppressed:
1375
+ requested.clear()
1376
+ break
1377
+ requested -= suppressed
1378
+ return requested
1379
+
1380
+
1381
+ def requests_approval_bypass(conversation: str) -> bool:
1382
+ lowered = str(conversation or "").lower()
1383
+ return bool(
1384
+ re.search(r"\b(?:without|no|skip|bypass|ignore|omit)\s+(?:any\s+)?approval\b", lowered)
1385
+ or re.search(r"\bapproval\s+(?:not\s+)?(?:needed|required|necessary)\b", lowered)
1386
+ or re.search(r"\bdon'?t\s+(?:ask for|request|require)\s+approval\b", lowered)
1387
+ )
1388
+
1389
+
1390
+ def requested_primary_content_effect(conversation: str) -> str | None:
1391
+ tokens = text_tokens(conversation)
1392
+ ordered_tokens = ordered_text_tokens(conversation)
1393
+ if any(
1394
+ token in tokens and not _term_is_negated(ordered_tokens, token)
1395
+ for token in {"recommend", "recommendation", "recommendations", "variant", "variants", "option", "options"}
1396
+ ):
1397
+ return "content.recommendation"
1398
+ if any(
1399
+ token in tokens and not _term_is_negated(ordered_tokens, token)
1400
+ for token in {"draft", "email", "outreach", "message"}
1401
+ ):
1402
+ return "content.draft"
1403
+ if any(token in tokens and not _term_is_negated(ordered_tokens, token) for token in {"summarize", "summary"}):
1404
+ return "content.summary"
1405
+ return None
1406
+
1407
+
1408
+ def should_clear_planner_unsupported_for_approval_boundary(
1409
+ conversation: str,
1410
+ capability_metadata: dict[str, Any],
1411
+ *,
1412
+ parameter_values: dict[str, Any] | None = None,
1413
+ requested_effects: set[str] | None = None,
1414
+ ) -> bool:
1415
+ """Allow approval-boundary capabilities to own safe compound requests.
1416
+
1417
+ Planner models sometimes mark a compound request unsupported because a
1418
+ secondary sub-intent is outside the selected approval capability. If the
1419
+ selected capability is itself the governed approval boundary and no
1420
+ explicitly blocked effect was detected from metadata, the service should
1421
+ respond with its declared approval/preview outcome instead of the agent
1422
+ denying the request pre-invocation.
1423
+ """
1424
+
1425
+ conditional_active = is_conditional_approval_boundary_active(capability_metadata, parameter_values)
1426
+ if not conditional_active and (not is_approval_capability(capability_metadata) or not has_approval_intent(conversation)):
1427
+ return False
1428
+ return not (requested_effects or requested_unsupported_effects(conversation, capability_metadata))
1429
+
1430
+
1431
+ def should_clear_planner_unsupported_for_declared_effect(
1432
+ conversation: str,
1433
+ capability_metadata: dict[str, Any],
1434
+ *,
1435
+ requested_effects: set[str] | None = None,
1436
+ ) -> bool:
1437
+ """Prefer deterministic contract effects over a model-only unsupported flag."""
1438
+
1439
+ if requested_effects or requested_unsupported_effects(conversation, capability_metadata):
1440
+ return False
1441
+ requested_effect = requested_primary_content_effect(conversation)
1442
+ if not requested_effect:
1443
+ return False
1444
+ return requested_effect in capability_produces(capability_metadata)
1445
+
1446
+
1447
+ def _term_is_negated(tokens: list[str], term: str) -> bool:
1448
+ for index, token in enumerate(tokens):
1449
+ if token != term:
1450
+ continue
1451
+ window = tokens[max(0, index - 6):index]
1452
+ if "without" in window or "not" in window or "no" in window or "exclude" in window or "avoid" in window:
1453
+ return True
1454
+ if len(window) >= 2 and window[-2:] == ["do", "not"]:
1455
+ return True
1456
+ return False
1457
+
1458
+
1459
+ def has_approval_intent(conversation: str) -> bool:
1460
+ tokens = text_tokens(conversation)
1461
+ matched_terms = tokens & APPROVAL_INTENT_TERMS
1462
+ if not matched_terms:
1463
+ return False
1464
+ ordered_tokens = ordered_text_tokens(conversation)
1465
+ return any(not _term_is_negated(ordered_tokens, term) for term in matched_terms)
1466
+
1467
+
1468
+ def is_approval_capability(capability_metadata: dict[str, Any]) -> bool:
1469
+ produced = capability_produces(capability_metadata)
1470
+ if {"approval.request", "system.preview_mutation"} & produced:
1471
+ return True
1472
+ approval = capability_metadata.get("app_profile", {}).get("approval") or capability_metadata.get("approval")
1473
+ return isinstance(approval, dict) and approval.get("required") is True
1474
+
1475
+
1476
+ def capability_match_score(conversation: str, capability_id: str, capability_metadata: dict[str, Any]) -> float:
1477
+ input_fragments: list[str] = []
1478
+ for item in capability_metadata.get("input_specs") or []:
1479
+ if not isinstance(item, dict):
1480
+ continue
1481
+ input_fragments.append(str(item.get("name") or ""))
1482
+ input_fragments.extend(str(value) for value in item.get("allowed_values") or [])
1483
+ input_names = " ".join(input_fragments)
1484
+ intent = capability_metadata.get("app_profile", {}).get("intent")
1485
+ intent_text = ""
1486
+ if isinstance(intent, dict):
1487
+ intent_text = f"{intent.get('category', '')} {intent.get('summary', '')}"
1488
+ profile_text = ""
1489
+ app_profile = capability_metadata.get("app_profile")
1490
+ if isinstance(app_profile, dict):
1491
+ profile_text = " ".join(
1492
+ str(value)
1493
+ for key in ("capability_framing", "input_meanings", "reference_catalogs", "app_boundaries")
1494
+ for value in [app_profile.get(key)]
1495
+ if value is not None
1496
+ )
1497
+ framing_text = " ".join(
1498
+ str(capability_metadata.get(key) or "")
1499
+ for key in ("capability_framing", "summary", "output_intent")
1500
+ )
1501
+ haystack = f"{capability_id} {capability_metadata.get('description', '')} {framing_text} {input_names} {intent_text} {profile_text}"
1502
+ customization = runtime_customization_for(capability_metadata)
1503
+ source_tokens = capability_score_token_variants(conversation, customization)
1504
+ target_tokens = capability_score_token_variants(haystack, customization)
1505
+ if not source_tokens or not target_tokens:
1506
+ return 0.0
1507
+ overlap = source_tokens & target_tokens
1508
+ recall = len(overlap) / max(1, len(source_tokens))
1509
+ precision = len(overlap) / max(1, len(target_tokens))
1510
+ id_tokens = capability_score_token_variants(capability_id, customization)
1511
+ id_precision = len(source_tokens & id_tokens) / max(1, len(id_tokens))
1512
+ return (recall * 0.65) + (precision * 0.25) + (id_precision * 0.10)
1513
+
1514
+
1515
+ READ_INTENT_TOKENS = {
1516
+ "biggest",
1517
+ "breakdown",
1518
+ "explain",
1519
+ "forecast",
1520
+ "health",
1521
+ "list",
1522
+ "rank",
1523
+ "ranking",
1524
+ "review",
1525
+ "show",
1526
+ "summarize",
1527
+ "summary",
1528
+ "top",
1529
+ "why",
1530
+ }
1531
+
1532
+
1533
+ def _conversation_has_read_intent(conversation: str) -> bool:
1534
+ tokens = text_tokens(conversation)
1535
+ return bool(tokens & READ_INTENT_TOKENS)
1536
+
1537
+
1538
+ def compact_capability_match_score(conversation: str, capability_id: str, capability_metadata: dict[str, Any]) -> float:
1539
+ """Score a capability for compact retrieval.
1540
+
1541
+ Compact retrieval has a stronger obligation than ordinary ranking: if it
1542
+ omits the right capability, the planner cannot recover. This score keeps
1543
+ the base semantic overlap but adds small contract-posture adjustments so
1544
+ read-only requests prefer read/summary capabilities over approval or
1545
+ mutation-preparation capabilities unless the conversation itself contains
1546
+ approval/write-adjacent intent.
1547
+ """
1548
+
1549
+ score = capability_match_score(conversation, capability_id, capability_metadata)
1550
+ produced = capability_produces(capability_metadata)
1551
+ read_intent = _conversation_has_read_intent(conversation)
1552
+ approval_intent = has_approval_intent(conversation)
1553
+ unsupported_effects = requested_unsupported_effects(conversation, capability_metadata)
1554
+ if read_intent and "content.summary" in produced and not is_approval_capability(capability_metadata):
1555
+ score += 0.12
1556
+ if read_intent and is_approval_capability(capability_metadata) and not approval_intent:
1557
+ score -= 0.08
1558
+ if unsupported_effects:
1559
+ score += 0.03
1560
+ return max(0.0, score)
1561
+
1562
+
1563
+ def missing_required_input_names(
1564
+ conversation: str,
1565
+ capability_metadata: dict[str, Any],
1566
+ parameter_values: dict[str, Any] | None = None,
1567
+ ) -> set[str]:
1568
+ seed_parameters = parameter_values if isinstance(parameter_values, dict) else {}
1569
+ inferred_parameters = normalize_declared_parameters(seed_parameters, conversation, capability_metadata)
1570
+ missing: set[str] = set()
1571
+ for input_spec in capability_metadata.get("input_specs", []):
1572
+ if not isinstance(input_spec, dict) or input_spec.get("required") is not True:
1573
+ continue
1574
+ name = str(input_spec.get("name") or "")
1575
+ if not name or name in inferred_parameters:
1576
+ continue
1577
+ resolution = input_spec.get("resolution") if isinstance(input_spec.get("resolution"), dict) else {}
1578
+ default_value = input_spec.get("default")
1579
+ if (
1580
+ default_value is not None
1581
+ and default_value != ""
1582
+ and default_value != []
1583
+ and resolution.get("on_missing") == "use_default"
1584
+ ):
1585
+ continue
1586
+ missing.add(name)
1587
+ return missing
1588
+
1589
+
1590
+ def _missing_required_inputs_are_referenced(
1591
+ conversation: str,
1592
+ capability_metadata: dict[str, Any],
1593
+ missing_inputs: set[str],
1594
+ ) -> bool:
1595
+ if not missing_inputs:
1596
+ return False
1597
+ conversation_tokens = text_tokens(conversation)
1598
+ if not conversation_tokens:
1599
+ return False
1600
+ weak_tokens = {"id", "ids", "input", "name", "names", "ref", "reference", "value", "values"}
1601
+ input_specs = {
1602
+ str(item.get("name") or ""): item
1603
+ for item in capability_metadata.get("input_specs", [])
1604
+ if isinstance(item, dict)
1605
+ }
1606
+ for input_name in missing_inputs:
1607
+ input_spec = input_specs.get(input_name, {})
1608
+ input_text = " ".join(
1609
+ str(value or "")
1610
+ for value in (
1611
+ input_name,
1612
+ input_spec.get("semantic_type"),
1613
+ input_spec.get("description"),
1614
+ )
1615
+ )
1616
+ input_tokens = text_tokens(input_text) - weak_tokens
1617
+ if not input_tokens or not (conversation_tokens & input_tokens):
1618
+ return False
1619
+ return True
1620
+
1621
+
1622
+ def _same_effect_class(first: dict[str, Any], second: dict[str, Any]) -> bool:
1623
+ first_produces = capability_produces(first)
1624
+ second_produces = capability_produces(second)
1625
+ if is_approval_capability(first) and is_approval_capability(second):
1626
+ return True
1627
+ return bool(first_produces & second_produces)
1628
+
1629
+
1630
+ def select_grounded_capability(
1631
+ conversation: str,
1632
+ selected_capability: str,
1633
+ metadata: dict[str, dict[str, Any]],
1634
+ parameter_values: dict[str, Any] | None = None,
1635
+ ) -> str:
1636
+ """Prefer a grounded peer when the model picked an ungrounded capability.
1637
+
1638
+ This is intentionally generic: if the selected capability cannot bind its
1639
+ required reviewed inputs from the user-authored conversation, do not keep it
1640
+ solely because the model chose it. A peer with the same effect class and
1641
+ grounded required inputs is safer because it can produce an executable ANIP
1642
+ invocation instead of falling through to avoidable clarification.
1643
+ """
1644
+
1645
+ selected_metadata = metadata[selected_capability]
1646
+ selected_missing = missing_required_input_names(conversation, selected_metadata, parameter_values)
1647
+ if not selected_missing:
1648
+ return selected_capability
1649
+
1650
+ selected_score = capability_match_score(conversation, selected_capability, selected_metadata)
1651
+ customization = runtime_customization_for(selected_metadata)
1652
+ min_score = _configured_float(customization, "grounded_peer_min_score", 0.12)
1653
+ margin = _configured_float(customization, "grounded_peer_margin", 0.02)
1654
+ different_missing_margin = _configured_float(customization, "grounded_peer_different_missing_margin", 0.25)
1655
+ best_capability = selected_capability
1656
+ best_score = 0.0
1657
+ best_missing_count = len(selected_missing)
1658
+
1659
+ for capability_id, capability_metadata in metadata.items():
1660
+ if capability_id == selected_capability:
1661
+ continue
1662
+ if not _same_effect_class(selected_metadata, capability_metadata):
1663
+ continue
1664
+ missing = missing_required_input_names(conversation, capability_metadata, parameter_values)
1665
+ if len(missing) > best_missing_count:
1666
+ continue
1667
+ score = capability_match_score(conversation, capability_id, capability_metadata)
1668
+ if len(missing) == best_missing_count:
1669
+ if missing != selected_missing:
1670
+ continue
1671
+ if score < selected_score + margin:
1672
+ continue
1673
+ if score > best_score or (best_capability == selected_capability and score == best_score):
1674
+ best_capability = capability_id
1675
+ best_score = score
1676
+ best_missing_count = len(missing)
1677
+
1678
+ if best_capability != selected_capability and (
1679
+ best_score >= max(min_score, selected_score + margin)
1680
+ or (best_missing_count < len(selected_missing) and best_score >= min_score)
1681
+ ):
1682
+ return best_capability
1683
+ return selected_capability
1684
+
1685
+
1686
+ def select_stronger_contract_match_capability(
1687
+ conversation: str,
1688
+ selected_capability: str,
1689
+ metadata: dict[str, dict[str, Any]],
1690
+ ) -> str:
1691
+ """Prefer a clearly better same-effect contract match.
1692
+
1693
+ This is deliberately narrower than free re-ranking. It only considers
1694
+ capabilities that share the selected capability's declared effect class, and
1695
+ it requires a material score margin. The safer outcome can be clarification:
1696
+ if the better-matching contract has missing required context, selecting it
1697
+ lets the service/runtime ask for that context instead of executing a nearby
1698
+ but semantically different capability.
1699
+ """
1700
+
1701
+ selected_metadata = metadata[selected_capability]
1702
+ selected_missing = missing_required_input_names(conversation, selected_metadata)
1703
+ selected_score = capability_match_score(conversation, selected_capability, selected_metadata)
1704
+ customization = runtime_customization_for(selected_metadata)
1705
+ min_score = _configured_float(customization, "stronger_contract_match_min_score", 0.12)
1706
+ margin = _configured_float(customization, "stronger_contract_match_margin", 0.08)
1707
+ best_capability = selected_capability
1708
+ best_score = selected_score
1709
+
1710
+ for capability_id, capability_metadata in metadata.items():
1711
+ if capability_id == selected_capability:
1712
+ continue
1713
+ if not _same_effect_class(selected_metadata, capability_metadata):
1714
+ continue
1715
+ missing = missing_required_input_names(conversation, capability_metadata)
1716
+ if selected_missing and not _missing_required_inputs_are_referenced(conversation, capability_metadata, missing):
1717
+ continue
1718
+ score = capability_match_score(conversation, capability_id, capability_metadata)
1719
+ if score > best_score:
1720
+ best_capability = capability_id
1721
+ best_score = score
1722
+
1723
+ if best_capability != selected_capability and best_score >= max(min_score, selected_score + margin):
1724
+ return best_capability
1725
+ return selected_capability
1726
+
1727
+
1728
+ def select_approval_boundary_capability(
1729
+ conversation: str,
1730
+ selected_capability: str,
1731
+ metadata: dict[str, dict[str, Any]],
1732
+ ) -> str:
1733
+ selected_metadata = metadata[selected_capability]
1734
+ if (
1735
+ is_approval_capability(selected_metadata)
1736
+ or "content.draft" in capability_produces(selected_metadata)
1737
+ or not has_approval_intent(conversation)
1738
+ ):
1739
+ return selected_capability
1740
+
1741
+ best_capability = selected_capability
1742
+ best_score = 0.0
1743
+ customization = runtime_customization_for(selected_metadata)
1744
+ min_score = _configured_float(customization, "approval_boundary_min_score", 0.12)
1745
+ for capability_id, capability_metadata in metadata.items():
1746
+ if not is_approval_capability(capability_metadata):
1747
+ continue
1748
+ score = capability_match_score(conversation, capability_id, capability_metadata)
1749
+ if score > best_score:
1750
+ best_capability = capability_id
1751
+ best_score = score
1752
+
1753
+ return best_capability if best_score >= min_score else selected_capability
1754
+
1755
+
1756
+ def select_declared_effect_capability(
1757
+ conversation: str,
1758
+ selected_capability: str,
1759
+ metadata: dict[str, dict[str, Any]],
1760
+ ) -> str:
1761
+ requested_effect = requested_primary_content_effect(conversation)
1762
+ if (
1763
+ not requested_effect
1764
+ or requested_effect in capability_produces(metadata[selected_capability])
1765
+ or is_approval_capability(metadata[selected_capability])
1766
+ or has_conditional_approval_boundary(metadata[selected_capability])
1767
+ ):
1768
+ return selected_capability
1769
+
1770
+ best_capability = selected_capability
1771
+ best_score = 0.0
1772
+ selected_metadata = metadata[selected_capability]
1773
+ selected_score = capability_match_score(conversation, selected_capability, selected_metadata)
1774
+ customization = runtime_customization_for(selected_metadata)
1775
+ min_score = _configured_float(customization, "effect_rewrite_min_score", 0.12)
1776
+ margin = _configured_float(customization, "effect_rewrite_margin", 0.1)
1777
+ for capability_id, capability_metadata in metadata.items():
1778
+ if requested_effect not in capability_produces(capability_metadata):
1779
+ continue
1780
+ score = capability_match_score(conversation, capability_id, capability_metadata)
1781
+ if score > best_score:
1782
+ best_capability = capability_id
1783
+ best_score = score
1784
+
1785
+ if best_capability != selected_capability and best_score >= max(min_score, selected_score + margin):
1786
+ return best_capability
1787
+ return selected_capability
1788
+
1789
+
1790
+ def select_requested_effect_floor_capability(
1791
+ conversation: str,
1792
+ selected_capability: str,
1793
+ metadata: dict[str, dict[str, Any]],
1794
+ ) -> str:
1795
+ """Avoid keeping a higher-effect capability when the user did not ask for it."""
1796
+
1797
+ selected_metadata = metadata[selected_capability]
1798
+ selected_produces = capability_produces(selected_metadata)
1799
+ requested_effect = requested_primary_content_effect(conversation)
1800
+ selected_needs_explicit_effect = (
1801
+ ("content.draft" in selected_produces and requested_effect != "content.draft")
1802
+ or (is_approval_capability(selected_metadata) and not has_approval_intent(conversation))
1803
+ )
1804
+ if is_approval_capability(selected_metadata) and requests_approval_bypass(conversation):
1805
+ return selected_capability
1806
+ if not selected_needs_explicit_effect:
1807
+ return selected_capability
1808
+
1809
+ selected_score = capability_match_score(conversation, selected_capability, selected_metadata)
1810
+ customization = runtime_customization_for(selected_metadata)
1811
+ min_score = _configured_float(customization, "effect_floor_min_score", 0.12)
1812
+ margin = _configured_float(customization, "effect_floor_margin", 0.02)
1813
+ preferred_effects = {requested_effect} if requested_effect else {"content.summary", "data.aggregate", "data.read"}
1814
+ best_capability = selected_capability
1815
+ best_score = 0.0
1816
+ for capability_id, capability_metadata in metadata.items():
1817
+ if capability_id == selected_capability:
1818
+ continue
1819
+ produces = capability_produces(capability_metadata)
1820
+ if "content.draft" in produces or is_approval_capability(capability_metadata) or has_conditional_approval_boundary(capability_metadata):
1821
+ continue
1822
+ if preferred_effects and not (produces & preferred_effects):
1823
+ continue
1824
+ score = capability_match_score(conversation, capability_id, capability_metadata)
1825
+ if score > best_score:
1826
+ best_capability = capability_id
1827
+ best_score = score
1828
+
1829
+ selected_lacks_requested_effect = bool(requested_effect and requested_effect not in selected_produces)
1830
+ required_score = min_score if selected_lacks_requested_effect else max(min_score, selected_score + margin)
1831
+ if best_capability != selected_capability and best_score >= required_score:
1832
+ return best_capability
1833
+ return selected_capability
1834
+
1835
+
1836
+ def matching_profile_hint(
1837
+ conversation: str,
1838
+ metadata: dict[str, dict[str, Any]],
1839
+ selection_hints: list[dict[str, Any]] | None = None,
1840
+ ) -> dict[str, Any] | None:
1841
+ lowered = conversation.lower()
1842
+ for hint in selection_hints or []:
1843
+ if not isinstance(hint, dict):
1844
+ continue
1845
+ capability_id = str(hint.get("capability") or "")
1846
+ if capability_id not in metadata:
1847
+ continue
1848
+ all_terms = [str(term).lower() for term in hint.get("all_terms") or []]
1849
+ any_terms = [str(term).lower() for term in hint.get("any_terms") or []]
1850
+ exclude_terms = [str(term).lower() for term in hint.get("exclude_terms") or []]
1851
+ if all_terms and not all(term in lowered for term in all_terms):
1852
+ continue
1853
+ if any_terms and not any(term in lowered for term in any_terms):
1854
+ continue
1855
+ if exclude_terms and any(term in lowered for term in exclude_terms):
1856
+ continue
1857
+ return hint
1858
+ return None
1859
+
1860
+
1861
+ def select_profile_hint_capability(
1862
+ conversation: str,
1863
+ selected_capability: str,
1864
+ metadata: dict[str, dict[str, Any]],
1865
+ selection_hints: list[dict[str, Any]] | None = None,
1866
+ ) -> str:
1867
+ hint = matching_profile_hint(conversation, metadata, selection_hints)
1868
+ if hint is not None:
1869
+ return str(hint.get("capability") or selected_capability)
1870
+ return selected_capability
1871
+
1872
+
1873
+ def select_consumable_capability(
1874
+ conversation: str,
1875
+ selected_capability: str,
1876
+ metadata: dict[str, dict[str, Any]],
1877
+ selection_hints: list[dict[str, Any]] | None = None,
1878
+ parameter_values: dict[str, Any] | None = None,
1879
+ ) -> str:
1880
+ matched_hint = matching_profile_hint(conversation, metadata, selection_hints)
1881
+ if matched_hint is not None and matched_hint.get("lock_capability") is True:
1882
+ return str(matched_hint.get("capability") or selected_capability)
1883
+ capability = select_profile_hint_capability(conversation, selected_capability, metadata, selection_hints)
1884
+ capability = select_grounded_capability(conversation, capability, metadata, parameter_values)
1885
+ capability = select_requested_effect_floor_capability(conversation, capability, metadata)
1886
+ capability = select_approval_boundary_capability(conversation, capability, metadata)
1887
+ capability = select_stronger_contract_match_capability(conversation, capability, metadata)
1888
+ return select_declared_effect_capability(conversation, capability, metadata)
1889
+
1890
+
1891
+ def normalize_declared_parameters(
1892
+ parameters: dict[str, Any],
1893
+ conversation: str,
1894
+ capability_metadata: dict[str, Any],
1895
+ ) -> dict[str, Any]:
1896
+ input_specs = [
1897
+ item
1898
+ for item in capability_metadata.get("input_specs") or []
1899
+ if isinstance(item, dict) and item.get("name")
1900
+ ]
1901
+ declared_inputs = {str(item.get("name")) for item in input_specs}
1902
+ allowed_values_by_input = {
1903
+ str(item.get("name")): list(all_candidate_values_for_input(capability_metadata, item).keys())
1904
+ for item in input_specs
1905
+ }
1906
+ input_spec_by_name = {str(item.get("name")): item for item in input_specs}
1907
+ filtered_parameters: dict[str, Any] = {}
1908
+ for key, value in parameters.items():
1909
+ if key not in declared_inputs or value is None or value == "":
1910
+ continue
1911
+ input_spec = input_spec_by_name.get(str(key), {})
1912
+ if not is_supported_quarter_input_value(input_spec, value, conversation):
1913
+ continue
1914
+ value = normalize_declared_input_value(input_spec, value, conversation)
1915
+ value = normalize_reference_value(input_spec, value, conversation, capability_metadata)
1916
+ if is_ungrounded_declared_context(input_spec, value, conversation, capability_metadata):
1917
+ continue
1918
+ allowed_values = allowed_values_by_input.get(str(key)) or []
1919
+ if isinstance(value, str) and allowed_values:
1920
+ normalized = next((allowed for allowed in allowed_values if semantic_text_key(allowed) == semantic_text_key(value)), None)
1921
+ if normalized is None:
1922
+ customization = runtime_customization_for(capability_metadata)
1923
+ normalized = canonical_from_candidates(
1924
+ value,
1925
+ conversation,
1926
+ {allowed: allowed.replace("_", " ") for allowed in allowed_values},
1927
+ customization,
1928
+ )
1929
+ if normalized is None:
1930
+ continue
1931
+ value = normalized
1932
+ filtered_parameters[key] = value
1933
+ for input_spec in input_specs:
1934
+ name = str(input_spec.get("name") or "")
1935
+ if not name or name in filtered_parameters:
1936
+ continue
1937
+ inferred = infer_declared_input_value(input_spec, conversation, capability_metadata)
1938
+ if inferred is not None:
1939
+ filtered_parameters[name] = inferred
1940
+ return filtered_parameters
1941
+
1942
+
1943
+ def normalize_invocation_plan(
1944
+ plan: dict[str, Any],
1945
+ conversation: str,
1946
+ metadata: dict[str, dict[str, Any]],
1947
+ *,
1948
+ selection_hints: list[dict[str, Any]] | None = None,
1949
+ ) -> dict[str, Any]:
1950
+ capability = str(plan.get("selected_capability") or "").strip()
1951
+ if not capability:
1952
+ raise ValueError("Model selected unsupported capability: <empty>")
1953
+ if capability not in metadata:
1954
+ raise ValueError(f"Model selected unsupported capability: {capability}")
1955
+ runtime_selection_hints = runtime_selection_hints_for(metadata)
1956
+ effective_selection_hints = [
1957
+ *(selection_hints or []),
1958
+ *runtime_selection_hints,
1959
+ ]
1960
+ user_conversation = user_authored_conversation_text(conversation)
1961
+ parameters = plan.get("parameters")
1962
+ if not isinstance(parameters, dict):
1963
+ raise ValueError("Model returned invalid parameters payload")
1964
+ capability = select_consumable_capability(user_conversation, capability, metadata, effective_selection_hints, parameters)
1965
+
1966
+ normalized_plan = dict(plan)
1967
+ filtered_parameters = normalize_declared_parameters(parameters, user_conversation, metadata[capability])
1968
+ normalized_plan["selected_capability"] = capability
1969
+ normalized_plan["parameters"] = filtered_parameters
1970
+
1971
+ unsupported_effects = requested_unsupported_effects(user_conversation, metadata[capability])
1972
+ missing_required = [
1973
+ str(input_spec.get("name") or "")
1974
+ for input_spec in metadata[capability].get("input_specs", [])
1975
+ if input_spec.get("required") is True
1976
+ and str(input_spec.get("name") or "")
1977
+ and str(input_spec.get("name") or "") not in filtered_parameters
1978
+ ]
1979
+ if unsupported_effects:
1980
+ normalized_plan["unsupported"] = True
1981
+ normalized_plan["unsupported_reason"] = (
1982
+ "The selected ANIP capability does not declare support for requested effect(s): "
1983
+ + ", ".join(sorted(unsupported_effects))
1984
+ )
1985
+ elif normalized_plan.get("unsupported") is True and supported_by_business_language_rule(
1986
+ user_conversation,
1987
+ metadata[capability],
1988
+ unsupported_effects,
1989
+ ):
1990
+ normalized_plan["unsupported"] = False
1991
+ normalized_plan["unsupported_reason"] = None
1992
+ elif normalized_plan.get("unsupported") is True and should_clear_planner_unsupported_for_approval_boundary(
1993
+ user_conversation,
1994
+ metadata[capability],
1995
+ parameter_values=filtered_parameters,
1996
+ requested_effects=unsupported_effects,
1997
+ ):
1998
+ normalized_plan["unsupported"] = False
1999
+ normalized_plan["unsupported_reason"] = None
2000
+ elif normalized_plan.get("unsupported") is True and should_clear_planner_unsupported_for_declared_effect(
2001
+ user_conversation,
2002
+ metadata[capability],
2003
+ requested_effects=unsupported_effects,
2004
+ ):
2005
+ normalized_plan["unsupported"] = False
2006
+ normalized_plan["unsupported_reason"] = None
2007
+ elif (
2008
+ normalized_plan.get("unsupported") is True
2009
+ and not unsupported_effects
2010
+ and _conversation_has_read_intent(user_conversation)
2011
+ and (capability_produces(metadata[capability]) & {"content.summary", "data.aggregate", "data.read"})
2012
+ ):
2013
+ normalized_plan["unsupported"] = False
2014
+ normalized_plan["unsupported_reason"] = None
2015
+ elif normalized_plan.get("unsupported") is True and missing_required:
2016
+ normalized_plan["unsupported"] = False
2017
+ normalized_plan["unsupported_reason"] = None
2018
+ return normalized_plan
2019
+
2020
+
2021
+ def conversation_text_from_history(question: str, history: list[dict[str, Any]] | None = None) -> str:
2022
+ parts: list[str] = []
2023
+ for item in history or []:
2024
+ if not isinstance(item, dict):
2025
+ continue
2026
+ role = str(item.get("role") or "").strip().lower()
2027
+ content = str(item.get("content") or "").strip()
2028
+ if role in {"user", "assistant"} and content:
2029
+ parts.append(f"{role}: {content}")
2030
+ parts.append(f"user: {question}")
2031
+ return "\n".join(parts)
2032
+
2033
+
2034
+ def user_authored_conversation_text(conversation: str) -> str:
2035
+ """Return only user-authored transcript lines when role prefixes are present."""
2036
+
2037
+ text = str(conversation or "")
2038
+ user_lines: list[str] = []
2039
+ saw_role_prefix = False
2040
+ for line in text.splitlines():
2041
+ stripped = line.strip()
2042
+ lowered = stripped.lower()
2043
+ if lowered.startswith("user:"):
2044
+ saw_role_prefix = True
2045
+ content = stripped.split(":", 1)[1].strip()
2046
+ if content:
2047
+ user_lines.append(f"user: {content}")
2048
+ elif lowered.startswith("assistant:"):
2049
+ saw_role_prefix = True
2050
+ if saw_role_prefix:
2051
+ return "\n".join(user_lines)
2052
+ return text
2053
+
2054
+
2055
+ def _failure_resolution(anip_result: dict[str, Any]) -> dict[str, Any]:
2056
+ failure = anip_result.get("failure")
2057
+ if not isinstance(failure, dict):
2058
+ return {}
2059
+ resolution = failure.get("resolution")
2060
+ return resolution if isinstance(resolution, dict) else {}
2061
+
2062
+
2063
+ def _clarification_missing_inputs(
2064
+ failure: dict[str, Any],
2065
+ capability_metadata: dict[str, Any] | None = None,
2066
+ ) -> list[str]:
2067
+ resolution = failure.get("resolution") if isinstance(failure.get("resolution"), dict) else {}
2068
+ requires = semantic_text_key(resolution.get("requires") if isinstance(resolution, dict) else "")
2069
+ input_specs = capability_metadata.get("input_specs") if isinstance(capability_metadata, dict) else []
2070
+ matches: list[str] = []
2071
+ if isinstance(input_specs, list):
2072
+ for input_spec in input_specs:
2073
+ if not isinstance(input_spec, dict):
2074
+ continue
2075
+ name = str(input_spec.get("name") or "").strip()
2076
+ if name and semantic_text_key(name) and semantic_text_key(name) in requires:
2077
+ matches.append(name)
2078
+ return matches
2079
+
2080
+
2081
+ def build_clarification_continuation(
2082
+ *,
2083
+ capability: str,
2084
+ parameters: dict[str, Any],
2085
+ anip_result: dict[str, Any],
2086
+ capability_metadata: dict[str, Any] | None = None,
2087
+ ) -> dict[str, Any] | None:
2088
+ failure = anip_result.get("failure")
2089
+ if not isinstance(failure, dict) or failure.get("type") != "clarification_required":
2090
+ return None
2091
+ resolution = _failure_resolution(anip_result)
2092
+ return {
2093
+ "type": "clarification",
2094
+ "capability": str(capability),
2095
+ "service": capability_metadata.get("service_name") if isinstance(capability_metadata, dict) else None,
2096
+ "parameters": dict(parameters),
2097
+ "missing_inputs": _clarification_missing_inputs(failure, capability_metadata),
2098
+ "requires": resolution.get("requires"),
2099
+ "action": resolution.get("action"),
2100
+ "failure_type": failure.get("type"),
2101
+ }
2102
+
2103
+
2104
+ def clarification_continuation_from_history(history: list[dict[str, Any]] | None) -> dict[str, Any] | None:
2105
+ for item in reversed(history or []):
2106
+ if not isinstance(item, dict) or str(item.get("role") or "").strip().lower() != "assistant":
2107
+ continue
2108
+ continuation = item.get("continuation")
2109
+ if isinstance(continuation, dict) and continuation.get("type") == "clarification":
2110
+ capability = str(continuation.get("capability") or "").strip()
2111
+ if capability:
2112
+ return continuation
2113
+ return None
2114
+
2115
+
2116
+ def _input_prompt_summary(capability_metadata: dict[str, Any]) -> list[dict[str, Any]]:
2117
+ summary: list[dict[str, Any]] = []
2118
+ for input_spec in capability_metadata.get("input_specs") or []:
2119
+ if not isinstance(input_spec, dict):
2120
+ continue
2121
+ name = str(input_spec.get("name") or "").strip()
2122
+ if not name:
2123
+ continue
2124
+ entry = {
2125
+ "name": name,
2126
+ "type": input_spec.get("type"),
2127
+ "required": input_spec.get("required") is True,
2128
+ "description": input_spec.get("description"),
2129
+ }
2130
+ if "allowed_values" in input_spec:
2131
+ entry["allowed_values"] = input_spec.get("allowed_values")
2132
+ if isinstance(input_spec.get("resolution"), dict):
2133
+ entry["resolution"] = input_spec.get("resolution")
2134
+ summary.append({key: value for key, value in entry.items() if value not in (None, "", [])})
2135
+ return summary
2136
+
2137
+
2138
+ def build_clarification_continuation_prompt(
2139
+ *,
2140
+ question: str,
2141
+ continuation: dict[str, Any],
2142
+ capability_metadata: dict[str, Any],
2143
+ ) -> str:
2144
+ return (
2145
+ "The previous ANIP invocation returned clarification_required. "
2146
+ "Decide whether the new user message answers that clarification for the same capability.\n"
2147
+ "Return JSON with exactly these fields:\n"
2148
+ "- intent_changed: boolean\n"
2149
+ "- parameters: object containing only declared inputs that the user supplied or corrected\n"
2150
+ "- rationale: short string\n"
2151
+ "- user_message: short string\n\n"
2152
+ "Rules:\n"
2153
+ "- Do not select a new capability unless the user clearly changed intent; set intent_changed=true in that case.\n"
2154
+ "- Do not invent missing values. Only extract values present in the new user message.\n"
2155
+ "- Use only declared input names.\n"
2156
+ "- Preserve the prior capability and prior parameters when intent_changed=false.\n\n"
2157
+ f"Capability: {continuation.get('capability')}\n"
2158
+ f"Declared inputs:\n{json.dumps(_input_prompt_summary(capability_metadata), ensure_ascii=False)}\n"
2159
+ f"Prior parameters:\n{json.dumps(continuation.get('parameters') or {}, ensure_ascii=False)}\n"
2160
+ f"Clarification requires: {continuation.get('requires') or continuation.get('missing_inputs') or 'unspecified'}\n"
2161
+ f"New user message:\n{question}"
2162
+ )
2163
+
2164
+
2165
+ def normalize_clarification_continuation_plan(
2166
+ plan: dict[str, Any],
2167
+ *,
2168
+ conversation: str,
2169
+ continuation: dict[str, Any],
2170
+ capability_metadata: dict[str, Any],
2171
+ ) -> dict[str, Any] | None:
2172
+ if plan.get("intent_changed") is True:
2173
+ return None
2174
+ capability = str(continuation.get("capability") or "").strip()
2175
+ if not capability:
2176
+ return None
2177
+ previous_parameters = continuation.get("parameters")
2178
+ proposed_parameters = plan.get("parameters")
2179
+ declared_inputs = {
2180
+ str(input_spec.get("name"))
2181
+ for input_spec in capability_metadata.get("input_specs") or []
2182
+ if isinstance(input_spec, dict) and str(input_spec.get("name") or "").strip()
2183
+ }
2184
+ merged_parameters = (
2185
+ {
2186
+ str(key): value
2187
+ for key, value in previous_parameters.items()
2188
+ if str(key) in declared_inputs and value not in (None, "")
2189
+ }
2190
+ if isinstance(previous_parameters, dict)
2191
+ else {}
2192
+ )
2193
+ if isinstance(proposed_parameters, dict):
2194
+ merged_parameters.update(normalize_declared_parameters(proposed_parameters, conversation, capability_metadata))
2195
+ inferred_parameters = normalize_declared_parameters({}, conversation, capability_metadata)
2196
+ for key, value in inferred_parameters.items():
2197
+ merged_parameters.setdefault(key, value)
2198
+ missing_inputs = [
2199
+ str(item).strip()
2200
+ for item in continuation.get("missing_inputs") or []
2201
+ if str(item or "").strip()
2202
+ ]
2203
+ if missing_inputs and any(input_name not in merged_parameters for input_name in missing_inputs):
2204
+ return None
2205
+ return {
2206
+ "selected_capability": capability,
2207
+ "parameters": merged_parameters,
2208
+ "unsupported": False,
2209
+ "unsupported_reason": None,
2210
+ "rationale": plan.get("rationale") or "Continue the previous clarification.",
2211
+ "user_message": plan.get("user_message") or "",
2212
+ "continuation": True,
2213
+ }