Setting the file. One moment.
Resolve · Dd Orchestrator · datadog-labs/agent-skills · Skills Docs
ContentsBack to the top of the page def __init__
— line 194
This file
Number 42.3
Position 3 of 4
Type Python
Size 46 KB
Lines 975 scripts/ resolve.py
Python · 975 lines · 46 KB
16 import difflib
17 import json
18 import os
19 import re
20 import sys
21 import uuid
22
23 HERE = os.path.dirname(os.path.abspath( __file__ )) # …/dd-orchestrator/scripts
24 SKILL = os.path.dirname( HERE ) # …/dd-orchestrator
25 CATALOG = os.path.join( SKILL , "catalog.json" )
26
27 KIND_RANK = { "foundation" : 0 , "cloud-connect" : 1 , "platform-install" : 2 ,
28 "product-enable" : 3 , "verify-troubleshoot" : 4 , "lifecycle" : 5 }
29
30 # recommender product name / alias -> catalog product token
31 PRODUCT_TOKENS = {
32 "apm" : "apm" , "application performance monitoring" : "apm" ,
33 "llm observability" : "llm-obs" , "llmo" : "llm-obs" , "llm obs" : "llm-obs" ,
34 "rum" : "rum" , "real user monitoring" : "rum" , "session replay" : "rum" ,
35 "error tracking" : "error-tracking" , "product analytics" : "product-analytics" ,
36 "infrastructure monitoring" : "infra" , "infra" : "infra" , "container monitoring" : "infra" ,
37 "log management" : "logs" , "logs" : "logs" ,
38 "database monitoring" : "dbm" , "dbm" : "dbm" ,
39 "cloud cost management" : "cost" , "ccm" : "cost" , "cloud cost" : "cost" ,
40 "cloud security" : "cloud-security" , "csm" : "cloud-security" , "cspm" : "cloud-security" , "cws" : "cloud-security" ,
41 "cloud siem" : "cloud-siem" ,
42 "app and api protection" : "aap" , "aap" : "aap" , "asm" : "aap" ,
43 "test optimization" : "test-optimization" , "ci visibility" : "test-optimization" ,
44 "code coverage" : "code-coverage" ,
45 "synthetic monitoring" : "synthetics" , "synthetics" : "synthetics" ,
46 "continuous profiler" : "profiler" , "profiler" : "profiler" ,
47 "network device monitoring" : "ndm" , "sensitive data scanner" : "sds" ,
48 "slo" : "slo" , "monitors" : "monitors" , "opentelemetry" : "otel" , "otel" : "otel" ,
49 "studio" : "studio" , "storage" : "storage" , "agent observability" : "agent-observability" ,
50 "mobile rum" : "rum-mobile" , "rum mobile" : "rum-mobile" , "mobile monitoring" : "rum-mobile" ,
51 "datadog app" : "apps" , "datadog apps" : "apps" , "dashboards app" : "apps" ,
52 }
53
54 # detected platform sub-variants -> the catalog platform token the Agent skills use.
55 # 'fargate' is deliberately NOT aliased: EKS-Fargate is kubernetes but ECS-Fargate is ecs, so
56 # collapsing it would pick the wrong installer (ROB-7); leave it to a dead-end/choice instead.
57 PLATFORM_ALIASES = {
58 "k8s" : "kubernetes" , "eks" : "kubernetes" , "gke" : "kubernetes" , "aks" : "kubernetes" ,
59 "openshift" : "kubernetes" , "oke" : "kubernetes" , "tkg" : "kubernetes" , "rancher" : "kubernetes" ,
60 "autopilot" : "kubernetes" ,
61 # Generic hosts/VMs do not imply Linux, and EC2/GCE do not reveal the guest
62 # operating system. GCR is a registry, not the Cloud Run compute platform.
63 "cloudrun" : "cloud-run" ,
64 # Serverless/PaaS deploy targets have no Agent installer; collapse the common ones to a
65 # single 'serverless' class so Agent-based products dead-end honestly (with a pointer)
66 # instead of offering an install choice that cannot match the context. (spec req #12)
67 "vercel" : "serverless" , "netlify" : "serverless" ,
68 }
69 CLOUD_ALIASES = {
70 "amazon" : "aws" , "amazon web services" : "aws" , "google" : "gcp" , "google cloud" : "gcp" ,
71 "microsoft azure" : "azure" , "az" : "azure" ,
72 }
73
74
75 def normalize_platform (p):
76 # ROB-2: lowercase the TOKEN itself (not just the lookup key), so canonical-but-
77 # capitalized inputs like "Kubernetes"/"Docker" resolve instead of dead-ending.
78 p2 = (p or "" ).strip().lower()
79 return PLATFORM_ALIASES .get(p2, p2)
80
81
82 def normalize_cloud (c):
83 c2 = (c or "" ).strip().lower()
84 return CLOUD_ALIASES .get(c2, c2)
85
86
87 # Serverless/PaaS platforms Datadog cannot instrument with an Agent installer. A product
88 # that needs a platform-install dead-ends here (with a pointer), never as a choice. (req #12)
89 SERVERLESS_PLATFORMS = { "serverless" }
90
91
92 def _serverless_pointer (platform):
93 """Pointer appended to a platform dead-end when the platform is serverless/PaaS
94 (no Agent installer exists). Empty string for every other platform."""
95 if platform in SERVERLESS_PLATFORMS :
96 return ( " — serverless/PaaS has no Agent installer; use the agentless integration "
97 "(e.g. the Vercel/Netlify <-> Datadog integration) or an agentless SDK, "
98 "see docs.datadoghq.com" )
99 return ""
100
101
102 def _cloud_suffix (cloud):
103 """Shared ' for cloud X' clause appended to a platform dead-end message, or ''."""
104 return f " for cloud ' { _san(cloud) } '" if cloud and cloud != "none" else ""
105
106
107 def _san (s, n = 80 ):
108 """ROB-8: sanitize free-text before echoing it into dead-end/choice strings."""
109 s = "" .join(ch if (ch >= " " and ch != " \x7f " ) else " " for ch in str (s))
110 s = " " .join(s.split())
111 return (s[:n] + "…" ) if len (s) > n else s
112
113
114 def load_catalog (path = CATALOG ):
115 with open (path) as f:
116 return json.load(f)
117
118
119 def canonical_key (node, by_key):
120 """Walk `duplicate_of` links to the canonical (non-duplicate) root key.
121
122 `by_key` maps every node's `key` to the node itself. Guards against cycles;
123 malformed catalogs that would cycle are rejected by check_catalog before
124 this ever runs on real data.
125 """
126 key, seen = node[ "key" ], set ()
127 while by_key.get(key, {}).get( "duplicate_of" ):
128 if key in seen:
129 break
130 seen.add(key)
131 key = by_key[key][ "duplicate_of" ]
132 return key
133
134
135 def display_name (token):
136 """First human alias in PRODUCT_TOKENS that maps to `token` (else the token itself)."""
137 return next ((k for k, v in PRODUCT_TOKENS .items() if v == token), token)
138
139
140 def normalize_product (name, catalog_tokens = None ):
141 """Map a product name/alias to its catalog token, or None if unrecognized.
142
143 Accepts the recommender names/aliases in PRODUCT_TOKENS and, when a set of
144 catalog product tokens is supplied, every such token as an alias for itself
145 (so an operator can pass the exact token printed in catalog.json). Case- and
146 whitespace-insensitive.
147 """
148 key = str (name).strip().lower()
149 if key in PRODUCT_TOKENS :
150 return PRODUCT_TOKENS [key]
151 if catalog_tokens and key in catalog_tokens:
152 return key
153 return None
154
155
156 # Orchestrator machinery, not user-facing products. Excluded from intent detection so
157 # "recommend what I need" (the canonical vague case) is never read as a named product.
158 _NON_PRODUCT_TOKENS = frozenset ({ "account" , "orchestrate" , "recommend" })
159
160
161 def detect_products (intent, catalog_tokens = None ):
162 """Scan a free-text intent for EXPLICITLY NAMED products; return their catalog tokens
163 ordered by first mention. Empty result => no product named => the caller falls back to
164 the recommender (spec req "Ability to get a shortcut and skip some steps").
165
166 Matches product NAMES, not the concepts they cover (word-boundary, longest-phrase-first):
167 "Agent Observability" (a name) matches; "track LLM model calls" (a description) does not.
168 The vocabulary is exactly what --products accepts (PRODUCT_TOKENS aliases + the catalog's
169 own tokens), minus orchestrator machinery — so it never drifts from the resolver.
170 """
171 if not intent:
172 return []
173 vocab = ( set ( PRODUCT_TOKENS ) | set (catalog_tokens or [])) - _NON_PRODUCT_TOKENS
174 # Longest phrase first so "mobile rum" wins over "rum"; the matched span is then consumed
175 # so a contained shorter alias cannot double-match. Fully ordered key => deterministic.
176 terms = sorted (vocab, key =lambda t: ( - t.count( " " ), - len (t), t))
177 original = intent.lower()
178 haystack = original
179 first_pos = {}
180 for term in terms:
181 pattern = r " \b " + r " \s + " .join(re.escape(w) for w in term.split()) + r " \b "
182 if not re.search(pattern, haystack):
183 continue
184 token = normalize_product(term, catalog_tokens)
185 if token:
186 here = re.search(pattern, original)
187 pos = here.start() if here else 0
188 first_pos[token] = min (pos, first_pos.get(token, pos))
189 haystack = re.sub(pattern, " " , haystack) # consume so a shorter alias can't re-match
190 return [t for t, _ in sorted (first_pos.items(), key =lambda kv: (kv[ 1 ], kv[ 0 ]))]
191
192
193 class Router :
194 def __init__ (self, catalog, enabled_only = True ):
195 # A canonical node identifies a capability; it is not necessarily the
196 # implementation available at runtime. Choose one implementation per duplicate
197 # group, preferring the canonical when it is enabled and otherwise falling back
198 # to a stable enabled duplicate. This prevents a disabled capability identity
199 # from hiding a genuinely equivalent enabled implementation.
200 nodes = [n for n in catalog[ "nodes" ] if n[ "kind" ] != "internal" ]
201 by_key = {n[ "key" ]: n for n in nodes}
202 # Every catalog product token is a first-class --products alias for itself, so an
203 # operator can pass the exact token printed in catalog.json (spec req #11). Derived
204 # from the catalog so it stays in sync as the catalog evolves.
205 self .product_tokens = frozenset (
206 n[ "product" ].strip().lower() for n in nodes if n.get( "product" )
207 )
208
209 groups = {}
210 for n in nodes:
211 groups.setdefault(canonical_key(n, by_key), []).append(n)
212
213 self .live = []
214 self .alias_ids_by_key = {}
215 self .by_id = {}
216 for root, implementations in groups.items():
217 canonical = by_key.get(root)
218 if enabled_only:
219 available = [n for n in implementations if n.get( "enabled" , True )]
220 if not available:
221 continue
222 chosen = canonical if canonical in available else min (available, key =lambda n: (n[ "id" ], n[ "key" ]))
223 else :
224 chosen = canonical or min (implementations, key =lambda n: (n[ "id" ], n[ "key" ]))
225 self .live.append(chosen)
226 aliases = {n[ "id" ] for n in implementations}
227 self .alias_ids_by_key[chosen[ "key" ]] = aliases
228 for alias in aliases:
229 self .by_id[alias] = chosen
230
231 self .account = next ((n for n in self .live if n[ "product" ] == "account" ), None )
232 self .install = {}
233 self .cloud = {}
234 self .enable = {}
235 self .verify = {}
236 self .triggered = {}
237 self .installer_products = set ()
238 for n in self .live:
239 if n[ "kind" ] == "platform-install" :
240 for platform in n[ "platform" ]:
241 self .install.setdefault(platform, []).append(n)
242 self .installer_products.update(n.get( "delivers" , []))
243 if n[ "kind" ] == "cloud-connect" and n[ "action" ] == "connect" :
244 for cloud in n[ "cloud" ]:
245 self .cloud.setdefault(cloud, []).append(n)
246 if n[ "kind" ] == "product-enable" :
247 self .enable.setdefault(n[ "product" ], []).append(n)
248 if n[ "kind" ] == "verify-troubleshoot" and "verify" in n[ "action" ]:
249 if n[ "product" ]:
250 self .verify.setdefault(n[ "product" ], []).append(n)
251 for product in n.get( "trigger_products" ) or []:
252 self .triggered.setdefault(product, []).append(n)
253
254 for index in ( self .install, self .cloud, self .enable, self .verify, self .triggered):
255 for values in index.values():
256 values.sort( key =lambda n: (n[ "id" ], n[ "key" ]))
257
258 @ staticmethod
259 def _facet_rank (node):
260 """Rank a matching node by constraint specificity, not list length."""
261 constrained = int ( bool (node[ "platform" ])) + int ( bool (node[ "cloud" ]))
262 return (constrained, - len (node[ "platform" ]), - len (node[ "cloud" ]))
263
264 @ staticmethod
265 def _first (candidates):
266 return candidates[ 0 ] if candidates else None
267
268 def _bind_soft (self, cat, ctx, fixed_cloud = None ):
269 """Bind a suggests edge; returns None (silently) if it cannot bind."""
270 if cat == "cloud-connect" :
271 c = fixed_cloud or ctx.get( "cloud" )
272 return self ._first( self .cloud.get(c, [])) if c and c != "none" else None
273 if cat == "platform-install" :
274 p = ctx.get( "platform" )
275 candidates = [n for n in self .install.get(p, []) if self ._fits(n, ctx)]
276 return self ._first(candidates) if p and p != "none" else None
277 if cat == "foundation" :
278 return self .account
279 return None
280
281 def _match (self, cands, ctx):
282 """Pick the candidate whose platform/cloud facets fit the detected context."""
283 best, best_score = None , None
284 for n in cands:
285 if n[ "platform" ] and ctx.get( "platform" ) not in n[ "platform" ]:
286 continue
287 if n[ "cloud" ] and ctx.get( "cloud" ) not in n[ "cloud" ]:
288 continue
289 score = self ._facet_rank(n)
290 if best_score is None or score > best_score:
291 best, best_score = n, score
292 return best
293
294 @ staticmethod
295 def _fits (node, ctx):
296 return (( not node[ "platform" ] or ctx.get( "platform" ) in node[ "platform" ]) and
297 ( not node[ "cloud" ] or ctx.get( "cloud" ) in node[ "cloud" ]))
298
299 def _nearest_product (self, name):
300 """Nearest known product for an unrecognized input: (display, token), or None.
301
302 Searches recommender names/aliases plus catalog tokens and returns the closest
303 only when it is a genuine near-miss (difflib cutoff 0.6): a typo like
304 "llmobs" -> "llm-obs" gets a "did you mean" hint, while garbage with no real
305 similarity returns None so the dead-end carries no fabricated suggestion.
306 """
307 vocab = list ( PRODUCT_TOKENS ) + sorted ( self .product_tokens)
308 match = difflib.get_close_matches( str (name).strip().lower(), vocab, n = 1 , cutoff = 0.6 )
309 if not match:
310 return None
311 token = normalize_product(match[ 0 ], self .product_tokens)
312 return display_name(token), token
313
314 def resolve (self, products, ctx, selections = None ):
315 """Resolve products for context.
316
317 ``selections`` maps a product token/name (or a returned choice ``need``) to
318 a skill id. It lets callers resolve a genuine capability choice without
319 relying on catalog order, e.g. ``{"agent-observability": "trace-rca"}``.
320 For convenience, the same mapping may be supplied as ``ctx["selections"]``.
321 """
322 raw_selections = dict (ctx.get( "selections" ) or {})
323 raw_selections.update(selections or {})
324 selected = {}
325 for name, skill_id in raw_selections.items():
326 normalized = normalize_product( str (name), self .product_tokens) or str (name).strip().lower()
327 selected[normalized] = str (skill_id).strip()
328 selected[ str (name).strip().lower()] = str (skill_id).strip()
329 ctx = { ** ctx,
330 "platform" : normalize_platform(ctx.get( "platform" )), # ROB-2: "Kubernetes"/"EKS" -> kubernetes
331 "cloud" : normalize_cloud(ctx.get( "cloud" ))}
332 include, dead_ends, choice_options, caveats = {}, [], {}, []
333 dependencies = set () # (dependent key, prerequisite key)
334
335 def add_choice (need, options):
336 options = set (options)
337 if need in choice_options:
338 # One detected context must satisfy every dependent capability.
339 choice_options[need].intersection_update(options)
340 else :
341 choice_options[need] = options
342
343 def requested_selection ( * names):
344 for name in names:
345 if name and name.lower() in selected:
346 return selected[name.lower()]
347 return None
348
349 def accepts_id (node, skill_id):
350 return skill_id == node[ "id" ] or skill_id in self .alias_ids_by_key.get(node[ "key" ], set ())
351
352 def choose_implementation (candidates, need, * selection_names):
353 """Choose among equally applicable, non-duplicate capabilities."""
354 candidates = sorted (candidates, key =lambda n: (n[ "id" ], n[ "key" ]))
355 if not candidates:
356 return None
357 if len (candidates) == 1 :
358 return candidates[ 0 ]
359 selection = requested_selection( * selection_names, need)
360 if selection:
361 matches = [n for n in candidates if accepts_id(n, selection)]
362 if len (matches) == 1 :
363 return matches[ 0 ]
364 dead_ends.append( f "selection ' { _san(selection) } ' is not available for { _san(need) } " )
365 add_choice(need, (n[ "id" ] for n in candidates))
366 return None
367
368 def product_candidate (tok, label):
369 cands = self .enable.get(tok, [])
370 if not cands:
371 dead_ends.append( f " { _san(label) } — no setup skill yet (not automated)" )
372 return None
373 matches = [n for n in cands if fits(n)]
374 if not matches:
375 plats = sorted ({p for n in cands for p in n[ "platform" ]})
376 clouds = sorted ({c for n in cands for c in n[ "cloud" ]})
377 if plats and ( not ctx.get( "platform" ) or ctx.get( "platform" ) == "none" ):
378 add_choice( "platform" , plats)
379 elif clouds and ( not ctx.get( "cloud" ) or ctx.get( "cloud" ) == "none" ):
380 add_choice( "cloud" , clouds)
381 else :
382 where = f "platform=' { _san(ctx.get( 'platform' )) } '" if plats else f "cloud=' { _san(ctx.get( 'cloud' )) } '"
383 covered = f " (covered: { ', ' .join(plats or clouds) } )" if (plats or clouds) else ""
384 pointer = _serverless_pointer(ctx.get( "platform" )) if plats else ""
385 dead_ends.append( f " { _san(label) } — not automated for { where }{ covered }{ pointer } " )
386 return None
387
388 selection = requested_selection(tok, f " { tok } capability" )
389 if selection:
390 selected_matches = [n for n in matches if accepts_id(n, selection)]
391 if len (selected_matches) == 1 :
392 return selected_matches[ 0 ]
393 dead_ends.append( f "selection ' { _san(selection) } ' is not available for { _san(tok) } capability" )
394 add_choice( f " { tok } capability" , (n[ "id" ] for n in matches))
395 return None
396
397 best = max ( self ._facet_rank(n) for n in matches)
398 top = [n for n in matches if self ._facet_rank(n) == best]
399 return choose_implementation(top, f " { tok } capability" , tok)
400
401 def add (n):
402 if n:
403 include[n[ "key" ]] = n
404 if n.get( "caveat" ):
405 caveats.append( f " { n[ 'id' ] } : { n[ 'caveat' ] } " )
406
407 def fits (n):
408 return self ._fits(n, ctx)
409
410 def bind (edge):
411 # Returns (node|None, ok). ok=False means a HARD prerequisite could not be met,
412 # so the dependent must NOT be planned (bind-then-add — CORR-1). Choice points
413 # are recorded as a side effect and also return ok=False.
414 if "skill" in edge:
415 t = self .by_id.get(edge[ "skill" ]); return (t, t is not None )
416 if "product" in edge: # CORR-4: product -> product
417 t = product_candidate(edge[ "product" ], f "prerequisite ' { edge[ 'product' ] } '" )
418 return (t, t is not None )
419 cat = edge.get( "category" )
420 if cat == "foundation" :
421 return ( self .account, self .account is not None )
422 if cat == "platform-install" :
423 p = ctx.get( "platform" )
424 if not p or p == "none" :
425 add_choice( "platform" , self .install); return ( None , False )
426 candidates = [n for n in self .install.get(p, []) if fits(n)]
427 generic = [n for n in candidates if n[ "action" ] in ( "install" , "install-agent" )]
428 n = choose_implementation(
429 generic or candidates,
430 f "platform-install: { p } capability" ,
431 f "platform-install: { p } " ,
432 )
433 if not n:
434 if not candidates:
435 cloud = ctx.get( "cloud" )
436 suffix = _cloud_suffix(cloud)
437 dead_ends.append(
438 f "Agent install for platform ' { _san(p) } ' { suffix } is not automated"
439 + _serverless_pointer(p))
440 return (n, n is not None )
441 if cat == "cloud-connect" :
442 c = edge.get( "cloud" ) or ctx.get( "cloud" )
443 if not c or c == "none" :
444 add_choice( "cloud" , self .cloud); return ( None , False )
445 n = choose_implementation(
446 self .cloud.get(c, []),
447 f "cloud-connect: { c } capability" ,
448 f "cloud-connect: { c } " ,
449 )
450 if not n:
451 if c not in self .cloud:
452 dead_ends.append( f "cloud integration for ' { _san(c) } ' is not automated" )
453 return (n, n is not None )
454 return ( None , True ) # unknown edge: not a hard blocker
455
456 def plan_node (n, visiting, visited):
457 # (ok, staged, edges): stage n and all satisfiable hard prerequisites.
458 # Keep visiting separate from visited so a real dependency cycle is a
459 # hard failure rather than being silently treated as a shared node.
460 if n[ "key" ] in visiting:
461 start = visiting.index(n[ "key" ])
462 cycle = visiting[start:] + [n[ "key" ]]
463 dead_ends.append( "dependency cycle: " + " -> " .join(cycle))
464 return ( False , {}, set ())
465 if n[ "key" ] in visited:
466 return ( True , {}, set ())
467 visiting.append(n[ "key" ])
468 staged, edges, ok = {n[ "key" ]: n}, set (), True
469 for r in n[ "requires" ]:
470 t, edge_ok = bind(r)
471 if not edge_ok:
472 ok = False ; continue
473 if t:
474 edges.add((n[ "key" ], t[ "key" ]))
475 sub_ok, sub, sub_edges = plan_node(t, visiting, visited)
476 staged.update(sub)
477 edges.update(sub_edges)
478 ok = ok and sub_ok
479 # A product pulled in as a hard prerequisite must complete its
480 # onboarding verification before the dependent runs, just as it
481 # would when requested directly.
482 if "product" in r:
483 for verifier in self .verify.get(r[ "product" ], []):
484 if not fits(verifier):
485 continue
486 edges.add((n[ "key" ], verifier[ "key" ]))
487 verify_ok, verify_nodes, verify_edges = plan_node(
488 verifier, visiting, visited)
489 staged.update(verify_nodes)
490 edges.update(verify_edges)
491 ok = ok and verify_ok
492 visiting.pop()
493 visited.add(n[ "key" ])
494 return (ok, staged, edges)
495
496 def stage (node, label):
497 dead_end_count = len (dead_ends)
498 choices_before = {need: set (options) for need, options in choice_options.items()}
499 ok, staged, edges = plan_node(node, [], set ())
500 if ok:
501 for v in staged.values():
502 add(v)
503 dependencies.update(edges)
504 return True
505 # Preserve a precise bind error or an actionable choice without adding
506 # a second, generic dead-end that tells the user nothing new.
507 choices_changed = choices_before != choice_options
508 if len (dead_ends) == dead_end_count and not choices_changed:
509 dead_ends.append(
510 f " { _san(label) } — not set up: a required prerequisite is unavailable for this context" )
511 return False
512
513 # Process products in a canonical order so the plan is INDEPENDENT of input order:
514 # installer-delivered products (infra/logs) go LAST, so they reuse an already-staged
515 # specific installer (e.g. apm-agent-install-*) instead of adding a redundant generic
516 # agent install. Non-installer products keep a stable token order. (combinatorial invariant)
517 def _proc_order (nm):
518 t = normalize_product(nm, self .product_tokens)
519 return ( 1 if t in self .installer_products else 0 , t or "" , str (nm))
520 for name in sorted (products, key = _proc_order):
521 tok = normalize_product(name, self .product_tokens)
522 if tok is None :
523 guess = self ._nearest_product(name)
524 hint = f ' — did you mean " { guess[ 0 ] } " ( { guess[ 1 ] } )?' if guess else ""
525 dead_ends.append( f "unrecognized product ' { _san(name) } ' { hint } " )
526 continue
527 if tok in self .installer_products: # delivered by an installer; no separate enable skill
528 p = ctx.get( "platform" )
529 if not p or p == "none" :
530 add_choice( "platform" , self .install); continue
531 candidates = []
532 for candidate in self .install.get(p, []):
533 delivered = candidate.get( "delivers" , [])
534 if tok in delivered and fits(candidate):
535 candidates.append(candidate)
536 already_staged = [n for n in candidates if n[ "key" ] in include]
537 generic = [n for n in candidates if n[ "action" ] in ( "install" , "install-agent" )]
538 node = choose_implementation(
539 already_staged or generic or candidates,
540 f "platform-install: { p } capability" ,
541 f "platform-install: { p } " ,
542 )
543 if not node:
544 if not candidates:
545 cloud = ctx.get( "cloud" )
546 suffix = _cloud_suffix(cloud)
547 if not self .install.get(p):
548 dead_ends.append(
549 f " { _san(name) } — Agent install for platform ' { _san(p) } ' is not automated"
550 + _serverless_pointer(p))
551 elif not any (fits(candidate) for candidate in self .install.get(p, [])):
552 dead_ends.append(
553 f " { _san(name) } — Agent install for platform ' { _san(p) } ' { suffix } "
554 "is not automated" )
555 else :
556 dead_ends.append(
557 f " { _san(name) } — Agent install for platform ' { _san(p) } ' { suffix } "
558 "does not deliver this product" )
559 continue
560 # Delivery is catalog data, not resolver policy. A platform installer
561 # without an explicit contract cannot be assumed to enable any product.
562 delivered_products = node.get( "delivers" , [])
563 if tok not in delivered_products:
564 dead_ends.append( f " { _san(name) } — Agent install for platform ' { _san(p) } ' does not deliver this product" )
565 continue
566 if stage(node, name):
567 # Some products have additive, context-specific setup paths. For
568 # example, cloud log forwarding belongs in a Logs plan only when
569 # that cloud was actually detected. Its own hard prerequisites are
570 # staged normally; it is never suggested for unrelated products.
571 for triggered in self .triggered.get(tok, []):
572 if fits(triggered):
573 stage(triggered, f " { name } ( { triggered[ 'id' ] } )" )
574 continue
575 m = product_candidate(tok, name)
576 if not m:
577 continue
578 if stage(m, name): # bind-then-add: only attach verify if the product itself resolved
579 for v in self .verify.get(tok, []):
580 if not fits(v):
581 continue
582 add(v)
583 dependencies.add((v[ "key" ], m[ "key" ]))
584
585 def stable_key (key):
586 n = include[key]
587 return ( KIND_RANK [n[ "kind" ]], n.get( "product" ) or "" , n[ "id" ], n[ "key" ])
588
589 # Linearize into a deterministic, sequential plan with dependency-chain
590 # LOCALITY: after a skill completes, its newly-unblocked direct dependents
591 # are preferred over unrelated nodes that were already ready, so a chain
592 # stays contiguous instead of interleaving with a sibling branch. Ties fall
593 # back to the stable key (kind, product, id, key). This is a depth-first walk
594 # of the hard-edge DAG; it stays independent of product input order because
595 # every choice is made by stable_key, not arrival order. `kind` still only
596 # orders nodes not already constrained by a hard edge (e.g. AAP->APM).
597 pending = {key: set () for key in include}
598 dependents = {key: [] for key in include}
599 for dependent, prerequisite in dependencies:
600 if dependent in pending and prerequisite in pending:
601 pending[dependent].add(prerequisite)
602 dependents[prerequisite].append(dependent)
603 plan = []
604 emitted = set ()
605 # LIFO frontier of ready keys. Push newly-ready keys in REVERSE stable order
606 # so the stable-min is popped first; the LIFO discipline drains the most
607 # recently unblocked chain before returning to older ready siblings.
608 frontier = sorted ((k for k, deps in pending.items() if not deps),
609 key = stable_key, reverse = True )
610 while frontier:
611 key = frontier.pop()
612 if key in emitted:
613 continue
614 plan.append(include[key])
615 emitted.add(key)
616 newly = []
617 for dep in dependents[key]:
618 pending[dep].discard(key)
619 if not pending[dep] and dep not in emitted:
620 newly.append(dep)
621 frontier.extend( sorted (newly, key = stable_key, reverse = True ))
622 if len (emitted) != len (include):
623 cycle = sorted ((k for k in include if k not in emitted), key = stable_key)
624 dead_ends.append( "dependency cycle among planned skills: " + ", " .join(cycle))
625
626 # dedupe by id, preserve order
627 seen_ids, ordered, ordered_keys = set (), [], []
628 for n in plan:
629 if n[ "id" ] in seen_ids:
630 continue
631 seen_ids.add(n[ "id" ])
632 ordered_keys.append(n[ "key" ])
633 ordered.append({ "id" : n[ "id" ], "kind" : n[ "kind" ], "product" : n[ "product" ],
634 "platform" : n[ "platform" ], "cloud" : n[ "cloud" ],
635 "url" : n[ "source" ][ "url" ], "source" : n[ "source" ]})
636
637 # A compact, human-readable view of the same plan as a DAG. Nodes stay in
638 # topological order, and `requires` contains only direct prerequisites.
639 position = {n[ "key" ]: i for i, n in enumerate (plan)}
640 requires_by_key = {n[ "key" ]: set () for n in plan}
641 for dependent, prerequisite in dependencies:
642 if dependent in requires_by_key and prerequisite in position:
643 requires_by_key[dependent].add(prerequisite)
644 dag = [
645 {
646 "skill" : include[key][ "id" ],
647 "requires" : [
648 include[required][ "id" ]
649 for required in sorted (requires_by_key[key], key = position.get)
650 ],
651 }
652 for key in ordered_keys
653 ]
654
655 # optional enrichments (suggests) — bound to context, never a dead-end/choice
656 plan_ids = {x[ "id" ] for x in ordered}
657 suggested = {}
658 for n in include.values():
659 for s in n.get( "suggests" , []):
660 t = self .by_id.get(s[ "skill" ]) if "skill" in s else self ._bind_soft(s.get( "category" ), ctx, s.get( "cloud" ))
661 if t and t[ "id" ] not in plan_ids and t[ "key" ] not in suggested:
662 suggested[t[ "key" ]] = { "id" : t[ "id" ], "kind" : t[ "kind" ], "product" : t[ "product" ],
663 "url" : t[ "source" ][ "url" ], "source" : t[ "source" ],
664 "suggested_by" : n[ "id" ]}
665 return { "plan" : ordered,
666 "dag" : dag,
667 "suggested" : list (suggested.values()),
668 "caveats" : list ( dict .fromkeys(caveats)),
669 "dead_ends" : list ( dict .fromkeys(dead_ends)),
670 "choices" : [{ "need" : need, "options" : sorted (options)}
671 for need, options in choice_options.items()]}
672
673
674 def _print (res):
675 # Render the plan as an aligned table so a debugger user sees each skill AND its
676 # full source URL in a separate column before anything is dispatched (spec req
677 # "Skill source URLs in output"). Rows keep the ` N. <id>` shape parsers rely on.
678 plan = res[ "plan" ]
679 print ( "PLAN (in order) — skills to execute, with source URLs:" )
680 if plan:
681 rows = [( f " { i } ." , p[ "id" ],
682 p[ "kind" ] + ( f "/ { p[ 'product' ] } " if p[ "product" ] else "" ),
683 p[ "url" ]) for i, p in enumerate (plan, 1 )]
684 nw = max ([ len (r[ 0 ]) for r in rows] + [ 1 ])
685 iw = max ([ len (r[ 1 ]) for r in rows] + [ len ( "SKILL" )])
686 kw = max ([ len (r[ 2 ]) for r in rows] + [ len ( "KIND" )])
687 print ( f " { '#' .ljust(nw) } { 'SKILL' .ljust(iw) } { 'KIND' .ljust(kw) } URL" )
688 for num, sid, kind, url in rows:
689 print ( f " { num.ljust(nw) } { sid.ljust(iw) } { kind.ljust(kw) } { url } " )
690 else :
691 print ( " (no skills to execute)" )
692 if res.get( "suggested" ):
693 print ( "SUGGESTED (optional enrichment for the detected context):" )
694 for s in res[ "suggested" ]:
695 print ( f " + { s[ 'id' ] } [ { s[ 'kind' ] } ] (suggested by { s[ 'suggested_by' ] } ) { s[ 'url' ] } " )
696 if res.get( "caveats" ):
697 print ( "CAVEATS (scope limits of a routed skill):" )
698 for c in res[ "caveats" ]:
699 print ( f " ! { c } " )
700 if res[ "dead_ends" ]:
701 print ( "DEAD-ENDS (recommended, not automated — demand signal):" )
702 for d in res[ "dead_ends" ]:
703 print ( f " - { d } " )
704 if res[ "choices" ]:
705 print ( "CHOICE POINTS (ask the developer):" )
706 for c in res[ "choices" ]:
707 print ( f " - pick a { c[ 'need' ] } : { ', ' .join(c[ 'options' ]) } " )
708
709
710 def _print_trace (session_id, args, res):
711 """Emit a stable, machine-readable TRACE block the SKILL.md runbook pastes verbatim
712 into the run's trace file (T2.1). The judge grades that trace, so having the deterministic
713 planner author it — instead of the model re-narrating the plan — removes a whole class
714 of transcription error. Deterministic for fixed inputs; SESSION_ID is the only
715 run-seeded field (pin it with DD_ORCH_SESSION_ID). CONFIRMED and DISPATCHED are
716 placeholders the agent fills after the confirm gate and after each dispatch.
717
718 STOP_REASON tells the reader whether the plan may proceed:
719 none — plan is non-empty and no choices remain; go to the confirm gate.
720 awaiting_choice — a CHOICE_POINT is unresolved (e.g. missing platform); resolve it
721 and re-run first, even if a partial plan already exists.
722 no_enabled_capability — no plan and no choice; only dead-ends (nothing to automate).
723 no_plan — nothing to do (empty product list / all filtered out).
724 """
725 plan = res[ "plan" ]
726 if res[ "choices" ]: # an unresolved choice blocks dispatch, even with a partial plan
727 stop = "awaiting_choice"
728 elif plan:
729 stop = "none"
730 elif res[ "dead_ends" ]:
731 stop = "no_enabled_capability"
732 else :
733 stop = "no_plan"
734 plat = normalize_platform(args.platform) or "none"
735 cl = normalize_cloud(args.cloud) or "none"
736 print ( "=== DD-ORCH TRACE v1 ===" )
737 print ( f "SESSION_ID: { session_id } " )
738 print ( f "CONTEXT: platform= { plat } cloud= { cl } " )
739 print ( f "STOP_REASON: { stop } " )
740 if plan:
741 print ( "PLAN:" )
742 for i, p in enumerate (plan, 1 ):
743 print ( f " { i } { p[ 'id' ] } { p[ 'kind' ] } { p[ 'product' ] or '-' } { p[ 'url' ] } " )
744 else :
745 print ( "PLAN: (empty)" )
746 if res[ "dead_ends" ]:
747 print ( "DEAD_ENDS:" )
748 for d in res[ "dead_ends" ]:
749 print ( f " - { d } " )
750 else :
751 print ( "DEAD_ENDS: (none)" )
752 if res[ "choices" ]:
753 print ( "CHOICE_POINTS:" )
754 for c in res[ "choices" ]:
755 print ( f " - { c[ 'need' ] } : { ', ' .join(c[ 'options' ]) } " )
756 else :
757 print ( "CHOICE_POINTS: (none)" )
758 if res.get( "suggested" ):
759 print ( "SUGGESTED:" ) # optional enrichment, e.g. a cloud connector
760 for s in res[ "suggested" ]:
761 print ( f " + { s[ 'id' ] } { s[ 'kind' ] } (by { s[ 'suggested_by' ] } ) { s[ 'url' ] } " )
762 else :
763 print ( "SUGGESTED: (none)" )
764 print ( "CONFIRMED: pending" )
765 print ( "DISPATCHED: (fill one skill_id per line after each dispatch)" )
766 print ( "=== END DD-ORCH TRACE ===" )
767
768
769 def _print_dag (res):
770 """Render the plan as an ASCII DAG (debug mode): indentation encodes dependency
771 depth so locality-ordered chains read as staircases, and `<-` lists each skill's
772 direct prerequisites so multi-parent edges stay explicit."""
773 dag = res[ "dag" ]
774 if not dag:
775 print ( "DAG: (empty — no skills to execute)" )
776 return
777 order = [n[ "skill" ] for n in dag]
778 reqs = {n[ "skill" ]: n[ "requires" ] for n in dag}
779 depth = {}
780 for skill in order: # plan order guarantees prerequisites precede dependents
781 rs = reqs[skill]
782 depth[skill] = 1 + max (depth[r] for r in rs) if rs else 0
783 print ( "DAG (skills to execute - indent = dependency depth, `<-` = direct prerequisites):" )
784 for skill in order:
785 edge = f " <- { ', ' .join(reqs[skill]) } " if reqs[skill] else ""
786 print ( f " { ' ' * (depth[skill] + 1 ) }{ skill }{ edge } " )
787
788
789 def _emit_telemetry (session_id, args, products, router, res):
790 """Emit resolve.py's three process-guaranteed events, best-effort (v1).
791
792 This is the RELIABLE CORE of the orchestrator's telemetry — the SKILL.md runbook
793 drives the per-dispatch skill_step events, which a markdown runbook cannot make
794 process-grade. It never affects the resolve result or the exit path: emit.py is
795 best-effort and the whole body is guarded, so any failure is swallowed.
796 """
797 try :
798 import emit
799
800 base = {
801 "invocation_mode" : "orchestrated" ,
802 "entry_skill_id" : "dd-orchestrator" ,
803 "agent_name" : getattr (args, "agent_name" , None ) or "unknown" ,
804 "intent_mode" : getattr (args, "intent_mode" , None ), # explicit (shortcut) | recommended
805 "target_platform" : normalize_platform(args.platform),
806 "target_cloud" : normalize_cloud(args.cloud),
807 "org_id" : os.environ.get( "DD_ORG_ID" ) or None , # auth'd org public_id; None -> omitted
808 }
809 # Persist the run envelope once so the SKILL.md runbook's later emit.py processes
810 # re-attach it to every started/finished/skill_run:finished event (F4).
811 emit.write_session_state(session_id, base, shape = {
812 "dead_end_count" : len (res[ "dead_ends" ]),
813 "planned_skill_count" : len (res[ "plan" ]),
814 "choice_count" : len (res[ "choices" ]),
815 })
816 emit.emit( "skill_run" , "started" , session_id, dict (base), critical = True )
817
818 tokens = sorted ({t for t in (normalize_product(p, router.product_tokens)
819 for p in products) if t})
820 dependency_count = {node[ "skill" ]: len (node[ "requires" ]) for node in res[ "dag" ]}
821 # F2: map each step to its direct prerequisites' plan positions so the DAG edges
822 # (not just a count) are reconstructable from logs.
823 position_of = {node[ "id" ]: i for i, node in enumerate (res[ "plan" ], 1 )}
824 requires_of = {node[ "skill" ]: node[ "requires" ] for node in res[ "dag" ]}
825 emit.emit( "skill_run" , "plan_resolved" , session_id, {
826 ** base,
827 "recommended_products" : "," .join(tokens),
828 "planned_skill_count" : len (res[ "plan" ]),
829 "dead_end_count" : len (res[ "dead_ends" ]),
830 "choice_count" : len (res[ "choices" ]),
831 }, critical = True )
832
833 for position, node in enumerate (res[ "plan" ], 1 ):
834 deps = sorted (position_of[r] for r in requires_of.get(node[ "id" ], [])
835 if r in position_of)
836 step = {
837 ** base,
838 "step_kind" : "skill" ,
839 "plan_position" : position,
840 "skill_id" : node[ "id" ],
841 "skill_kind" : node[ "kind" ],
842 "product" : node.get( "product" ) or "" ,
843 "source_repo" : (node.get( "source" ) or {}).get( "repo" ) or "" ,
844 "dependency_count" : dependency_count.get(node[ "id" ], 0 ),
845 "skill_invoked" : False ,
846 "instrumentation_invoked" : False ,
847 }
848 if deps: # omit for root steps (no edges)
849 step[ "depends_on" ] = "," .join( str (p) for p in deps)
850 emit.emit( "skill_step" , "planned" , session_id, step, critical = True )
851
852 # ponytail: one coverage_gap per dead-end, counted only. resolve.py's dead_ends are
853 # free text and privacy (proposal §8) forbids echoing them; splitting them into
854 # per-product tokens is a follow-up. dead_end_count (above) already carries the total.
855 for _dead_end in res[ "dead_ends" ]:
856 emit.emit( "skill_step" , "planned" , session_id, {
857 ** base,
858 "step_kind" : "coverage_gap" ,
859 "skill_invoked" : False ,
860 "instrumentation_invoked" : False ,
861 "result" : "not_automated" ,
862 }, critical = True )
863 except Exception :
864 return
865
866
867 def main ():
868 ap = argparse.ArgumentParser()
869 ap.add_argument( "--products" , default = "" )
870 ap.add_argument( "--platform" , default = "none" )
871 ap.add_argument( "--cloud" , default = "none" )
872 ap.add_argument(
873 "--select" ,
874 action = "append" ,
875 default = [],
876 metavar = "PRODUCT=SKILL_ID" ,
877 help = "resolve an implementation choice (repeatable)" ,
878 )
879 ap.add_argument(
880 "--include-disabled" ,
881 action = "store_true" ,
882 help = "route over the full capability model, including disabled (private / not-GA) "
883 "implementations; shows the path a skill would take once its source is enabled" ,
884 )
885 ap.add_argument(
886 "--debug" ,
887 action = "store_true" ,
888 help = "also render the ASCII DAG of the skills to be executed" ,
889 )
890 ap.add_argument(
891 "--trace" ,
892 action = "store_true" ,
893 help = "emit a stable, machine-readable TRACE block (STOP_REASON, PLAN, DEAD_ENDS, "
894 "CHOICE_POINTS, CONFIRMED, DISPATCHED) for the run's trace file instead of the human table" ,
895 )
896 ap.add_argument(
897 "--list-products" ,
898 action = "store_true" ,
899 help = "print the accepted product vocabulary (recommender names + catalog tokens) and exit" ,
900 )
901 ap.add_argument(
902 "--detect-products" ,
903 default = None ,
904 metavar = "INTENT" ,
905 help = "scan a free-text intent for explicitly named products; print the matched product "
906 "tokens (one CSV line, empty if none) and exit. Empty output => run the recommender" ,
907 )
908 ap.add_argument(
909 "--agent-name" ,
910 default = "unknown" ,
911 help = "executor agent for telemetry: claude_code | codex | cursor | unknown" ,
912 )
913 ap.add_argument(
914 "--intent-mode" ,
915 choices = ( "explicit" , "recommended" ),
916 default = None ,
917 help = "product provenance (REQUIRED with --trace): 'explicit' if the intent named the products "
918 "(--detect-products shortcut), 'recommended' if dd-product-recommender produced them. The "
919 "orchestrator may not compose a plan from products it inferred itself." ,
920 )
921 a = ap.parse_args()
922 selections = {}
923 for item in a.select:
924 if "=" not in item:
925 ap.error( f "--select must be PRODUCT=SKILL_ID, got { item !r} " )
926 product, skill_id = item.split( "=" , 1 )
927 if not product.strip() or not skill_id.strip():
928 ap.error( f "--select must be PRODUCT=SKILL_ID, got { item !r} " )
929 selections[product.strip()] = skill_id.strip()
930 catalog = load_catalog()
931 if a.list_products:
932 router = Router(catalog, enabled_only =not a.include_disabled)
933 print ( "Accepted --products inputs (case-insensitive, whitespace-tolerant):" )
934 print ( " names/aliases: " + ", " .join( sorted ( PRODUCT_TOKENS )))
935 print ( " catalog tokens: " + ", " .join( sorted (router.product_tokens)))
936 return 0
937 if a.detect_products is not None :
938 # Shortcut: the intent may already name products. If so, skip the recommender and
939 # route them directly; empty output tells the runbook to recommend instead.
940 router = Router(catalog, enabled_only =not a.include_disabled)
941 print ( "," .join(detect_products(a.detect_products, router.product_tokens)))
942 return 0
943 # Gate: a composed trace must declare product provenance — the --detect-products shortcut or
944 # dd-product-recommender. The orchestrator may not build a plan from products it inferred itself.
945 if a.trace and a.products.strip() and not a.intent_mode:
946 ap.error( "--trace with --products requires --intent-mode explicit|recommended: products must "
947 "come from the --detect-products shortcut or dd-product-recommender, never inferred "
948 "by the orchestrator" )
949 # One session id per invocation, shared by every event in this DAG run. Minted here
950 # (or taken from the environment if an outer wrapper already set it) and printed so the
951 # SKILL.md runbook can reuse it on each dispatch-boundary emit.py call.
952 session_id = os.environ.get( "DD_ORCH_SESSION_ID" ) or str (uuid.uuid4())
953 if not a.trace:
954 # In --trace mode the framed block already carries SESSION_ID; keep this human-oriented
955 # line for plain runs only, so the trace stays a single machine-readable block.
956 print ( f "SESSION ID: { session_id } " )
957 products = [p for p in a.products.split( "," ) if p.strip()]
958 router = Router(catalog, enabled_only =not a.include_disabled)
959 res = router.resolve(
960 products,
961 { "platform" : a.platform, "cloud" : a.cloud},
962 selections = selections,
963 )
964 if a.trace:
965 _print_trace(session_id, a, res)
966 else :
967 _print(res)
968 if a.debug:
969 _print_dag(res)
970 _emit_telemetry(session_id, a, products, router, res)
971 return 0
972
973
974 if __name__ == "__main__" :
975 sys.exit(main())