datagoat-decide 0.2.0__tar.gz

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.
Files changed (33) hide show
  1. datagoat_decide-0.2.0/.gitignore +15 -0
  2. datagoat_decide-0.2.0/PKG-INFO +57 -0
  3. datagoat_decide-0.2.0/README.md +42 -0
  4. datagoat_decide-0.2.0/pyproject.toml +33 -0
  5. datagoat_decide-0.2.0/src/datagoat_decide/__init__.py +5 -0
  6. datagoat_decide-0.2.0/src/datagoat_decide/cli.py +279 -0
  7. datagoat_decide-0.2.0/src/datagoat_decide/client.py +495 -0
  8. datagoat_decide-0.2.0/src/datagoat_decide/decorator.py +58 -0
  9. datagoat_decide-0.2.0/src/datagoat_decide/events.py +78 -0
  10. datagoat_decide-0.2.0/src/datagoat_decide/fallback.py +67 -0
  11. datagoat_decide-0.2.0/src/datagoat_decide/history_csv.py +72 -0
  12. datagoat_decide-0.2.0/src/datagoat_decide/install.py +146 -0
  13. datagoat_decide-0.2.0/src/datagoat_decide/langgraph.py +180 -0
  14. datagoat_decide-0.2.0/src/datagoat_decide/scan.py +398 -0
  15. datagoat_decide-0.2.0/src/datagoat_decide/skill/SKILL.md +120 -0
  16. datagoat_decide-0.2.0/src/datagoat_decide/traces.py +360 -0
  17. datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/adk_agent.py +9 -0
  18. datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/broken.py +1 -0
  19. datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/claude_sdk.py +9 -0
  20. datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/crew.py +5 -0
  21. datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/dspy_mod.py +9 -0
  22. datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/fraud_openai_agents.py +4 -0
  23. datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/leads_pydantic_ai.py +4 -0
  24. datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/raw_openai.py +13 -0
  25. datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/retention.py +22 -0
  26. datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/support_agent.py +10 -0
  27. datagoat_decide-0.2.0/tests/fixtures/scan_agent/pyproject.toml +0 -0
  28. datagoat_decide-0.2.0/tests/fixtures/scan_agent/schemas/models.py +30 -0
  29. datagoat_decide-0.2.0/tests/test_install.py +86 -0
  30. datagoat_decide-0.2.0/tests/test_scan.py +97 -0
  31. datagoat_decide-0.2.0/tests/test_sdk.py +433 -0
  32. datagoat_decide-0.2.0/tests/test_traces.py +215 -0
  33. datagoat_decide-0.2.0/uv.lock +1509 -0
@@ -0,0 +1,15 @@
1
+ node_modules
2
+ dist
3
+ .next
4
+ .turbo
5
+ .trigger
6
+ .env
7
+ .env.local
8
+ *.tsbuildinfo
9
+ next-env.d.ts
10
+ .vercel
11
+ __pycache__
12
+ .venv
13
+ .pytest_cache
14
+ *.egg-info
15
+ python/datagoat-decide/dist
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.5
2
+ Name: datagoat-decide
3
+ Version: 0.2.0
4
+ Summary: Let Datagoat answer a decision step in your agent, with your AI as the fallback.
5
+ Project-URL: Homepage, https://datagoat.io
6
+ Project-URL: Repository, https://github.com/dmilstein-match/datagoat-gde
7
+ License-Expression: MIT
8
+ Keywords: agent,datagoat,decision,fallback,langgraph
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: httpx>=0.27
11
+ Provides-Extra: langgraph
12
+ Requires-Dist: langgraph>=0.4; extra == 'langgraph'
13
+ Requires-Dist: pydantic>=2; extra == 'langgraph'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # datagoat-decide (Python)
17
+
18
+ ```bash
19
+ pip install datagoat-decide # or: uv add datagoat-decide
20
+ export DATAGOAT_API_KEY=dg_live_...
21
+ ```
22
+
23
+ ## Wrap a LangGraph node
24
+
25
+ ```python
26
+ from datagoat_decide import Datagoat
27
+ dg = Datagoat() # reads DATAGOAT_API_KEY and DATAGOAT_BASE_URL (your data1 deployment URL)
28
+
29
+ graph.add_node("qualify", dg.wrap("sdr.qualify", qualify_lead,
30
+ input_key="lead", output_key="qualification", case_id="lead_id"))
31
+ ```
32
+
33
+ * **Valid answer** → Datagoat's answer goes into `state["qualification"]`, your node does not run.
34
+ * **No valid answer** → your fallback (set in the dashboard): your node runs as before, or the case
35
+ goes to a person, or no decision (`None`).
36
+ * **Datagoat unreachable or slow** → your node runs. The SDK never blocks your agent past its
37
+ budget (`timeout`, default 2.5 s). Every call carries an idempotency key, so a call that timed
38
+ out after Datagoat logged it is replayed off your agent's path and still graded.
39
+
40
+ ## Any function
41
+
42
+ ```python
43
+ @dg.point("cs.risk_tier", case_id=lambda acct: acct["account_id"])
44
+ def risk_tier(acct: dict) -> dict: ...
45
+ ```
46
+
47
+ ## Outcomes
48
+
49
+ ```python
50
+ dg.outcomes("sdr.qualify", [{"case_id": "L-20419", "value": 1}])
51
+ ```
52
+
53
+ ## Map your agent
54
+
55
+ ```bash
56
+ datagoat-decide map my_agent.graph:graph --name "SDR agent"
57
+ ```
@@ -0,0 +1,42 @@
1
+ # datagoat-decide (Python)
2
+
3
+ ```bash
4
+ pip install datagoat-decide # or: uv add datagoat-decide
5
+ export DATAGOAT_API_KEY=dg_live_...
6
+ ```
7
+
8
+ ## Wrap a LangGraph node
9
+
10
+ ```python
11
+ from datagoat_decide import Datagoat
12
+ dg = Datagoat() # reads DATAGOAT_API_KEY and DATAGOAT_BASE_URL (your data1 deployment URL)
13
+
14
+ graph.add_node("qualify", dg.wrap("sdr.qualify", qualify_lead,
15
+ input_key="lead", output_key="qualification", case_id="lead_id"))
16
+ ```
17
+
18
+ * **Valid answer** → Datagoat's answer goes into `state["qualification"]`, your node does not run.
19
+ * **No valid answer** → your fallback (set in the dashboard): your node runs as before, or the case
20
+ goes to a person, or no decision (`None`).
21
+ * **Datagoat unreachable or slow** → your node runs. The SDK never blocks your agent past its
22
+ budget (`timeout`, default 2.5 s). Every call carries an idempotency key, so a call that timed
23
+ out after Datagoat logged it is replayed off your agent's path and still graded.
24
+
25
+ ## Any function
26
+
27
+ ```python
28
+ @dg.point("cs.risk_tier", case_id=lambda acct: acct["account_id"])
29
+ def risk_tier(acct: dict) -> dict: ...
30
+ ```
31
+
32
+ ## Outcomes
33
+
34
+ ```python
35
+ dg.outcomes("sdr.qualify", [{"case_id": "L-20419", "value": 1}])
36
+ ```
37
+
38
+ ## Map your agent
39
+
40
+ ```bash
41
+ datagoat-decide map my_agent.graph:graph --name "SDR agent"
42
+ ```
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "datagoat-decide"
3
+ version = "0.2.0"
4
+ description = "Let Datagoat answer a decision step in your agent, with your AI as the fallback."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ dependencies = ["httpx>=0.27"]
9
+ keywords = ["datagoat", "agent", "decision", "langgraph", "fallback"]
10
+
11
+ [project.urls]
12
+ Homepage = "https://datagoat.io"
13
+ Repository = "https://github.com/dmilstein-match/datagoat-gde"
14
+
15
+ [project.optional-dependencies]
16
+ langgraph = ["langgraph>=0.4", "pydantic>=2"]
17
+
18
+ [project.scripts]
19
+ datagoat-decide = "datagoat_decide.cli:run"
20
+
21
+ [dependency-groups]
22
+ dev = ["pytest>=8", "langgraph>=0.4", "pydantic>=2"]
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/datagoat_decide"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ pythonpath = ["src"]
@@ -0,0 +1,5 @@
1
+ """Datagoat: let Datagoat answer a decision step in your agent, with your AI as the fallback."""
2
+ from .client import Datagoat, Decision, DecisionOutput
3
+
4
+ __all__ = ["Datagoat", "Decision", "DecisionOutput"]
5
+ __version__ = "0.2.0"
@@ -0,0 +1,279 @@
1
+ """datagoat-decide: connect an agent to Datagoat.
2
+
3
+ scan [path] | scan --traces FILE find decision steps in source (never imported) or in traces
4
+ install add the MCP server and skill to your coding agents
5
+ history FILE.csv past cases with outcomes (or with --events, outcomes from a log)
6
+ backfill TRACES past decisions of your AI, from traces, so a point starts with a record
7
+ outcomes EVENTS.csv outcomes for recorded decisions, from a raw event log
8
+ map module:attr read a compiled LangGraph"""
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import importlib
13
+ import json
14
+ import os
15
+ import sys
16
+
17
+ from .client import Datagoat
18
+ from .install import CLIENTS, install
19
+ from .scan import scan
20
+
21
+
22
+ def _print_scan(body, proposals=None) -> None:
23
+ steps = body["steps"]
24
+ if not steps:
25
+ print("No decision steps found: no model call here returns a yes/no, a fixed set of values or a bounded score.")
26
+ return
27
+ by_step = {p["step_id"]: p for p in (proposals or [])}
28
+ print(f"Found {len(steps)} decision step(s) ({body['framework']}):")
29
+ for s in steps:
30
+ p = by_step.get(s["id"])
31
+ loc = f"{s['location']['file']}:{s['location']['line']}" if s.get("location") else (s.get("note") or "")
32
+ status = (p or {}).get("status") or ("wired" if s.get("wired_point") else "new")
33
+ point = (p or {}).get("point") or s.get("wired_point") or s["proposed_point"]
34
+ what = ""
35
+ if p:
36
+ what = f"choice of {', '.join(p['options'])}" if p["question"] == "choice" else (
37
+ f"yes/no, tiers {'/'.join(p['tier_labels'])}" if p.get("tier_labels") else "yes/no")
38
+ else:
39
+ what = ", ".join(f"{f['name']} ({f['kind']})" for f in s["fields"]) or "schema in trace"
40
+ print(f" [{status}] {point} {loc} {s['framework']}/{s['scope']} {what}")
41
+
42
+
43
+ def _event_args(p, prefix: str, required: bool) -> None:
44
+ if prefix:
45
+ p.add_argument("--events", metavar="FILE", help="event log CSV (case id, event, time) to take outcomes from")
46
+ p.add_argument(f"--{prefix}case-id" if prefix else "--case-id", dest="ev_case_id", required=required and not prefix,
47
+ help="event log column with the case id" + (" (default: --case-id)" if prefix else ""))
48
+ p.add_argument(f"--{prefix}name" if prefix else "--event", dest="ev_event", required=required, help="event log column with the event name")
49
+ p.add_argument(f"--{prefix}time" if prefix else "--time", dest="ev_time", required=required, help="event log column with when it happened")
50
+ p.add_argument("--yes", dest="ev_yes", required=required, help="comma-separated events that mean the outcome happened")
51
+ p.add_argument("--no", dest="ev_no", help="comma-separated events that mean it did not (optional)")
52
+
53
+
54
+ def _split(v):
55
+ return [x.strip() for x in v.split(",") if x.strip()] if v else []
56
+
57
+
58
+ def main(argv=None) -> int:
59
+ ap = argparse.ArgumentParser(prog="datagoat-decide")
60
+ sub = ap.add_subparsers(dest="cmd", required=True)
61
+ sc = sub.add_parser("scan", help="find decision steps in source code without importing it")
62
+ sc.add_argument("path", nargs="?", default=".")
63
+ sc.add_argument("--name", help="agent name shown in the dashboard (default: the folder name)")
64
+ sc.add_argument("--json", action="store_true", help="print the agent map as JSON")
65
+ sc.add_argument("--send", action="store_true", help="send the map to Datagoat (DATAGOAT_API_KEY) and print its proposals")
66
+ sc.add_argument("--traces", metavar="FILE", help="read an exported trace file (OTLP JSON, LangSmith runs, Langfuse observations) instead of source")
67
+ sc.add_argument("--min-runs", type=int, default=5, help="traces: runs a step needs before its outputs are read (default 5)")
68
+ ins = sub.add_parser("install", help="connect your coding agents (Claude Code, Cursor, VS Code, Codex, Gemini, Windsurf) to Datagoat")
69
+ ins.add_argument("--url", help="your Datagoat URL (default: DATAGOAT_BASE_URL)")
70
+ ins.add_argument("--client", help="comma-separated: " + ", ".join(CLIENTS) + ", or all (default: detected)")
71
+ ins.add_argument("--auth", choices=["oauth", "key"], default="oauth", help="oauth: sign in from the client; key: read DATAGOAT_API_KEY")
72
+ ins.add_argument("--dir", default=".")
73
+ ins.add_argument("--dry-run", action="store_true")
74
+ h = sub.add_parser("history", help="import past cases with outcomes from a CSV export for a declared point")
75
+ h.add_argument("file")
76
+ h.add_argument("--point", required=True)
77
+ h.add_argument("--case-id", required=True, help="column that identifies a case (the point's entity)")
78
+ h.add_argument("--time", required=True, help="column with when each case happened")
79
+ h.add_argument("--outcome", help="the outcome column (or give --events)")
80
+ h.add_argument("--positive", default="1", help='value of the outcome column that means it happened (default "1"; case-insensitive)')
81
+ h.add_argument("--fields", help="comma-separated input columns (default: every other column); use the point's input fields")
82
+ h.add_argument("--dry-run", action="store_true", help="read and count, send nothing")
83
+ _event_args(h, prefix="event-", required=False)
84
+ h.add_argument("--horizon-days", type=int, help="with --events: the point's horizon (days from a case to its outcome)")
85
+
86
+ o = sub.add_parser("outcomes", help="send outcomes for recorded decisions from a raw event log (case id, event, time)")
87
+ o.add_argument("file")
88
+ o.add_argument("--point", required=True)
89
+ _event_args(o, prefix="", required=True)
90
+ o.add_argument("--dry-run", action="store_true")
91
+
92
+ b = sub.add_parser("backfill", help="send past decisions of your AI, from an exported trace file, as the point's record")
93
+ b.add_argument("file")
94
+ b.add_argument("--point", required=True)
95
+ b.add_argument("--step", help="span / run name of the decision step (default: the only decision step found)")
96
+ b.add_argument("--case-id", required=True, help="input field (dotted path ok) that identifies a case")
97
+ b.add_argument("--fields", required=True, help="comma-separated input fields to send: the point's input fields. Nothing else leaves this machine")
98
+ b.add_argument("--acted-when", metavar="FIELD=V1,V2", help="yes/no points: the AI acted when this output field has one of these values (default: read a bool field)")
99
+ b.add_argument("--option-from", metavar="FIELD", help="choice points: the output field with the option used")
100
+ b.add_argument("--dry-run", action="store_true")
101
+ m = sub.add_parser("map", help="map a compiled LangGraph: module.path:attribute (a graph, or a function returning one)")
102
+ m.add_argument("target")
103
+ m.add_argument("--name", help="agent name shown in the dashboard")
104
+ m.add_argument("--dry-run", action="store_true", help="print the map instead of sending it")
105
+ a = ap.parse_args(argv)
106
+
107
+ if a.cmd == "scan":
108
+ if a.traces:
109
+ from .traces import detect, load
110
+ runs = load(a.traces)
111
+ body = detect(runs, agent=a.name or os.path.splitext(os.path.basename(a.traces))[0], min_runs=a.min_runs)
112
+ if not a.json:
113
+ print(f"Read {len(runs)} spans/runs from {a.traces}.")
114
+ else:
115
+ body = scan(a.path, name=a.name)
116
+ if a.send:
117
+ if not body["steps"]:
118
+ _print_scan(body)
119
+ return 1
120
+ res = Datagoat().post_agent_map(body)
121
+ if a.json:
122
+ print(json.dumps(res, indent=2))
123
+ else:
124
+ _print_scan(body, res.get("proposals"))
125
+ print(f"agent_id {res.get('agent_id')}")
126
+ return 0
127
+ if a.json:
128
+ print(json.dumps(body, indent=2))
129
+ else:
130
+ _print_scan(body)
131
+ return 0
132
+
133
+ if a.cmd == "history":
134
+ from .history_csv import prepare
135
+ if not a.outcome and not a.events:
136
+ print("Give --outcome (a column in the file) or --events (an event log to take outcomes from).")
137
+ return 2
138
+ p = prepare(a.file, a.case_id, a.time, None if a.events else a.outcome, a.positive, _split(a.fields) or None)
139
+ if p["missing_columns"]:
140
+ print(f"Missing from the file: {', '.join(p['missing_columns'])}.")
141
+ return 2
142
+ rows, sk = p["rows"], p["skipped"]
143
+ if a.events:
144
+ from .events import _dt, label_case, read_events
145
+ if not (a.ev_event and a.ev_time and a.ev_yes and a.horizon_days):
146
+ print("--events needs --event-name, --event-time, --yes and --horizon-days.")
147
+ return 2
148
+ ev = read_events(a.events, a.ev_case_id or a.case_id, a.ev_event, a.ev_time, _split(a.ev_yes), _split(a.ev_no))
149
+ if ev["missing_columns"]:
150
+ print(f"Missing from the event log: {', '.join(ev['missing_columns'])}.")
151
+ return 2
152
+ c = ev["counts"]
153
+ print(f"Event log: {c['rows']} events, {c['yes']} yes, {c['no']} no, {c['other']} other; last at {ev['last'].isoformat() if ev['last'] else 'none'}.")
154
+ labelled, still_open = [], 0
155
+ for r in rows:
156
+ y = label_case(_dt(r["at"]), ev["by_case"].get(r["case_id"], []), a.horizon_days, ev["last"])
157
+ if y is None:
158
+ still_open += 1
159
+ else:
160
+ labelled.append({**r, "outcome": y})
161
+ rows = labelled
162
+ sk = {**sk, "blank_outcome": still_open}
163
+ pos = sum(r["outcome"] for r in rows)
164
+ print(f"{len(rows)} cases ready, {pos} with the outcome; fields {', '.join(p['fields'])}.")
165
+ if any(sk.values()):
166
+ print(f"Left out: {sk['blank_outcome']} with no outcome yet, {sk['bad_time']} with an unreadable date, {sk['no_id']} with no {a.case_id}.")
167
+ if rows and pos in (0, len(rows)) and not a.events:
168
+ print(f'Warning: every case has the same outcome. Check --positive (values are compared to "{a.positive}").')
169
+ if a.dry_run or not rows:
170
+ return 0 if rows else 1
171
+ res = Datagoat().history(a.point, rows)
172
+ print(f"Imported {res['imported']} cases into {a.point}. Sending the file again is safe: a case sent twice replaces itself.")
173
+ return 0
174
+
175
+ if a.cmd == "outcomes":
176
+ from .events import outcomes_from_events, read_events
177
+ ev = read_events(a.file, a.ev_case_id, a.ev_event, a.ev_time, _split(a.ev_yes), _split(a.ev_no))
178
+ if ev["missing_columns"]:
179
+ print(f"Missing from the file: {', '.join(ev['missing_columns'])}.")
180
+ return 2
181
+ c = ev["counts"]
182
+ rows = outcomes_from_events(ev)
183
+ print(f"{c['rows']} events: {c['yes']} mean it happened, {c['no']} mean it did not, {c['other']} ignored; {len(ev['by_case'])} cases.")
184
+ if c["bad_time"] or c["no_id"]:
185
+ print(f"Left out: {c['bad_time']} with an unreadable time, {c['no_id']} with no case id.")
186
+ if a.dry_run or not rows:
187
+ return 0 if rows else 1
188
+ from .client import batches
189
+ dg, sent, matched = Datagoat(), 0, 0
190
+ for chunk in batches(rows):
191
+ r = dg.outcomes(a.point, chunk)
192
+ sent += r["accepted"]
193
+ matched += r["matched_decisions"]
194
+ print(f"Sent {sent} outcomes to {a.point}; {matched} cases match a recorded decision. Datagoat labels each decision by its horizon.")
195
+ return 0
196
+
197
+ if a.cmd == "backfill":
198
+ from .traces import backfill, detect, load
199
+ runs = load(a.file)
200
+ step = a.step
201
+ if not step:
202
+ found = [s["name"] for s in detect(runs)["steps"]]
203
+ if len(found) != 1:
204
+ print(f"Pass --step: {'decision steps in this file: ' + ', '.join(found) if found else 'no decision step found in this file'}.")
205
+ return 2
206
+ step = found[0]
207
+ acted = None
208
+ if a.acted_when:
209
+ f, _, vals = a.acted_when.partition("=")
210
+ acted = (f.strip(), _split(vals))
211
+ r = backfill(runs, step, a.case_id, _split(a.fields), flag_from=acted, option_from=a.option_from)
212
+ ds = r["decisions"]
213
+ print(f"{r['runs']} runs of {step}; {len(ds)} decisions ready" + (f"; left out: {', '.join(f'{v} {k}' for k, v in r['skipped'].items())}" if r["skipped"] else "") + ".")
214
+ if ds:
215
+ print(f"Fields: {', '.join(ds[0]['inputs'])}. First: {ds[0]['decided_at']}, last: {ds[-1]['decided_at']}.")
216
+ if not a.option_from and not any("flag" in d for d in ds):
217
+ print("Note: no yes/no read from the outputs. Pass --acted-when FIELD=VALUES so outcomes the AI prevented are not counted as real 0s.")
218
+ from datetime import datetime, timedelta, timezone
219
+ old = sum(1 for d in ds if d["decided_at"] < (datetime.now(timezone.utc) - timedelta(days=90)).isoformat().replace("+00:00", "Z"))
220
+ if old:
221
+ print(f"Note: {old} of them are more than 90 days old; refits read the last 90 days plus the horizon, so the oldest may not be used.")
222
+ if a.dry_run or not ds:
223
+ return 0 if ds else 1
224
+ res = Datagoat().backfill(a.point, ds)
225
+ print(f"Backfilled {res['inserted']} past decisions into {a.point} ({res['already_had']} were already there"
226
+ + (f", {res['logged_live']} skipped because the wrapped step already logged them" if res.get("logged_live") else "")
227
+ + (f", {res['unknown_option']} with an option that isn't declared" if res.get("unknown_option") else "") + ").")
228
+ if res.get("unflagged"):
229
+ print(f"Warning: {res['unflagged']} have no yes/no from your AI, so the ones it turned away will count as 'no'. Re-run with --acted-when.")
230
+ print("Now send their outcomes: datagoat-decide outcomes <events.csv> …")
231
+ return 0
232
+
233
+ if a.cmd == "install":
234
+ url = a.url or os.environ.get("DATAGOAT_BASE_URL")
235
+ if not url:
236
+ print("Pass --url with your Datagoat URL (shown on the dashboard's Connect page), or set DATAGOAT_BASE_URL.")
237
+ return 2
238
+ clients = CLIENTS if a.client == "all" else ([c.strip() for c in a.client.split(",") if c.strip()] if a.client else None)
239
+ bad = [c for c in clients or [] if c not in CLIENTS]
240
+ if bad:
241
+ print(f"Unknown client {', '.join(bad)}. One of: {', '.join(CLIENTS)}, all.")
242
+ return 2
243
+ d = os.path.abspath(a.dir)
244
+ changes = install(d, url, clients=clients, auth=a.auth, dry_run=a.dry_run)
245
+ for c in changes:
246
+ rel = os.path.relpath(c["file"], d)
247
+ print(f"{'would ' if a.dry_run else ''}{c['action']:<9} {c['file'] if rel.startswith('..') else rel}{' ' + c['note'] if c.get('note') else ''}")
248
+ print("Set DATAGOAT_API_KEY where the client runs. Then restart it and ask it to connect this agent to Datagoat." if a.auth == "key"
249
+ else "Restart the client; it opens a sign-in the first time it uses Datagoat. Then ask it to connect this agent to Datagoat.")
250
+ return 1 if any(c["action"] == "manual" for c in changes) else 0
251
+
252
+ from .langgraph import map_graph # needs langgraph importable; scan does not
253
+
254
+ mod_name, _, attr = a.target.partition(":")
255
+ sys.path.insert(0, ".")
256
+ obj = getattr(importlib.import_module(mod_name), attr or "graph")
257
+ graph = obj() if callable(obj) and not hasattr(obj, "get_graph") else obj
258
+ steps = map_graph(graph, module_prefix=mod_name.split(".")[-1])
259
+ body = {"name": a.name or mod_name, "framework": "langgraph", "steps": steps}
260
+ if a.dry_run:
261
+ print(json.dumps(body, indent=2))
262
+ return 0
263
+ res = Datagoat().post_agent_map(body)
264
+ found = [s for s in steps if s["kind"] == "decision"]
265
+ print(f"Mapped {len(steps)} steps, {len(found)} decision point(s): {', '.join(s['proposed_point'] for s in found) or 'none'}")
266
+ print(f"agent_id {res.get('agent_id')}")
267
+ return 0
268
+
269
+
270
+ def run() -> int:
271
+ try:
272
+ return main()
273
+ except (RuntimeError, ValueError, FileNotFoundError) as e:
274
+ print(f"datagoat-decide: {e}", file=sys.stderr)
275
+ return 1
276
+
277
+
278
+ if __name__ == "__main__":
279
+ raise SystemExit(run())