corethink 1.0.0__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.
Files changed (48) hide show
  1. corethink/__init__.py +17 -0
  2. corethink/__main__.py +3 -0
  3. corethink/_version.py +3 -0
  4. corethink/account.py +470 -0
  5. corethink/cli.py +1058 -0
  6. corethink/client.py +253 -0
  7. corethink/commands/__init__.py +0 -0
  8. corethink/commands/admin.py +703 -0
  9. corethink/commands/api.py +59 -0
  10. corethink/commands/archive.py +433 -0
  11. corethink/commands/auth.py +375 -0
  12. corethink/commands/billing.py +314 -0
  13. corethink/commands/capture.py +564 -0
  14. corethink/commands/connection.py +1266 -0
  15. corethink/commands/endpoint.py +1124 -0
  16. corethink/commands/env.py +1966 -0
  17. corethink/commands/hub.py +269 -0
  18. corethink/commands/library.py +347 -0
  19. corethink/commands/models.py +258 -0
  20. corethink/commands/train.py +2655 -0
  21. corethink/commands/workspace.py +622 -0
  22. corethink/config.py +181 -0
  23. corethink/context.py +46 -0
  24. corethink/env_template.py +117 -0
  25. corethink/environment_http_store.py +96 -0
  26. corethink/experiment_cli.py +2187 -0
  27. corethink/hosted_profiles.py +44 -0
  28. corethink/hosted_rl.py +688 -0
  29. corethink/hosted_rl_archive.py +74 -0
  30. corethink/hosted_rl_attribution.py +44 -0
  31. corethink/hosted_rl_billing.py +103 -0
  32. corethink/hosted_rl_state.py +182 -0
  33. corethink/jobs_cli.py +839 -0
  34. corethink/main.py +204 -0
  35. corethink/output.py +201 -0
  36. corethink/py.typed +0 -0
  37. corethink/rl_cli.py +997 -0
  38. corethink/rl_inspect.py +521 -0
  39. corethink/rl_playground.py +129 -0
  40. corethink/rl_reassessment.py +214 -0
  41. corethink/rl_settings.py +43 -0
  42. corethink/sdk.py +331 -0
  43. corethink/settings.py +162 -0
  44. corethink-1.0.0.dist-info/METADATA +120 -0
  45. corethink-1.0.0.dist-info/RECORD +48 -0
  46. corethink-1.0.0.dist-info/WHEEL +5 -0
  47. corethink-1.0.0.dist-info/entry_points.txt +2 -0
  48. corethink-1.0.0.dist-info/top_level.txt +1 -0
corethink/cli.py ADDED
@@ -0,0 +1,1058 @@
1
+ """Thin CoreThink command-line front door."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from collections.abc import Sequence
10
+ from dataclasses import replace
11
+ from pathlib import Path
12
+
13
+ import httpx
14
+ from corethink_evals import (
15
+ DEFAULT_SYSTEM_INSTRUCTIONS,
16
+ ArtifactReader,
17
+ EvaluationRunner,
18
+ NetworkMode,
19
+ OpenAICompatibleModelClient,
20
+ ProgramSource,
21
+ ProgramSpec,
22
+ PullPolicy,
23
+ RunConfig,
24
+ RuntimeResources,
25
+ RuntimeSelection,
26
+ TaskStatus,
27
+ initialize_program_definition,
28
+ load_program_definition,
29
+ load_taskset,
30
+ )
31
+ from corethink_evals.audit import RequestCorrelation, build_audit_bundle, write_audit_bundle
32
+ from corethink_evals.program_budget import DIAL_FIELDS, ReasoningEffort, resolve_effort
33
+ from corethink_experiments.errors import ExperimentError
34
+
35
+ from corethink import experiment_cli, jobs_cli
36
+ from corethink.account import AccountError
37
+ from corethink.config import ConfigError
38
+ from corethink.jobs_cli import JobsError
39
+
40
+ DEFAULT_BASE_URL = "https://nre-kernel-xfsavzevuq-uc.a.run.app/v1"
41
+ DEFAULT_MODEL = "corethink/nre-kernel"
42
+ DEFAULT_PROGRAM_ID = "corethink/local-eval"
43
+ DEFAULT_PROGRAM_VERSION = "0.1.0"
44
+ DEFAULT_MODEL_API_KEY_ENV = "CORETHINK_MODEL_API_KEY"
45
+
46
+
47
+ #: Named endpoints, so the common case is a word rather than three flags.
48
+ #: Deliberately small and explicit: a preset that guesses a base URL from a model
49
+ #: name would send a customer's prompts somewhere they did not choose.
50
+ AGENT_PRESETS: dict[str, dict[str, str]] = {
51
+ "corethink": {
52
+ "base_url": DEFAULT_BASE_URL,
53
+ "model": DEFAULT_MODEL,
54
+ "description": "the public CoreThink Reasoner; needs no key",
55
+ },
56
+ "openrouter": {
57
+ "base_url": "https://openrouter.ai/api/v1",
58
+ # Match Workbench's hosted evaluation default. Explicit --model wins;
59
+ # the paid preset never silently falls back to a different/free model.
60
+ "model": "deepseek/deepseek-v4.1-flash",
61
+ "api_key_env": "OPENROUTER_API_KEY",
62
+ "description": "paid DeepSeek V4.1 Flash via OpenRouter; requires API key and credits",
63
+ },
64
+ "fake": {
65
+ "adapter": "fake",
66
+ "description": "the taskset's own build_fake_client; no key, no network",
67
+ },
68
+ }
69
+
70
+
71
+ def build_parser() -> argparse.ArgumentParser:
72
+ parser = argparse.ArgumentParser(
73
+ prog="corethink",
74
+ description="Evaluate versioned agent programs with replayable stateful traces.",
75
+ )
76
+ commands = parser.add_subparsers(dest="command", required=True)
77
+
78
+ evaluate = commands.add_parser("eval", help="run a local taskset evaluation")
79
+ evaluate.add_argument("taskset", help="taskset.py file or directory containing it")
80
+ evaluate.add_argument("--adapter", choices=("live", "fake"), default="live")
81
+ # Off by default. Requesting logprobs from an endpoint that does not support
82
+ # them can be rejected outright, so an evaluation that never wanted them must
83
+ # not start failing. Deliberately not a program-config key: config feeds the
84
+ # program hash, so capture there would make the same program with capture on a
85
+ # different program, and its RL data incomparable with its own baseline.
86
+ evaluate.add_argument(
87
+ "--temperature",
88
+ type=float,
89
+ default=0.0,
90
+ help="sampling temperature. 0 is deterministic, which makes every sample of "
91
+ "a prompt identical — and a GRPO group of identical answers has no gradient "
92
+ "signal, so collecting RL data needs this above 0.",
93
+ )
94
+ evaluate.add_argument("--top-p", type=float, default=None)
95
+ evaluate.add_argument(
96
+ "--seed",
97
+ type=int,
98
+ default=None,
99
+ help="reproduce one rollout exactly. Not for building groups: one seed makes "
100
+ "k samples identical, which is the thing a group must not be.",
101
+ )
102
+ evaluate.add_argument(
103
+ "--capture-logprobs",
104
+ action="store_true",
105
+ help="record per-token log-probabilities, which a policy gradient needs "
106
+ "and supervised fine-tuning does not",
107
+ )
108
+ # No parser default: with one, an explicit flag and the fallback are
109
+ # indistinguishable at resolution time, and a stored base_url could never
110
+ # take effect. The default is applied last, in `_resolved`.
111
+ evaluate.add_argument("--base-url")
112
+ evaluate.add_argument("--model")
113
+ evaluate.add_argument(
114
+ "--model-api-key-env",
115
+ default=DEFAULT_MODEL_API_KEY_ENV,
116
+ help="environment variable holding the model API key for --adapter live; "
117
+ "never passed on argv. Unset means the endpoint is called unauthenticated, "
118
+ "which is correct for the public CoreThink Reasoner and wrong for anything else.",
119
+ )
120
+ evaluate.add_argument(
121
+ "agent",
122
+ nargs="?",
123
+ help=(
124
+ "a named endpoint instead of --adapter/--base-url/--model: "
125
+ + ", ".join(sorted(AGENT_PRESETS))
126
+ + ". Explicit flags still win over the preset."
127
+ ),
128
+ )
129
+ evaluate.add_argument("--out", default=".corethink", help="artifact root directory")
130
+ evaluate.add_argument(
131
+ "--group",
132
+ type=_positive_int,
133
+ default=1,
134
+ metavar="K",
135
+ help=(
136
+ "sample the whole taskset K times instead of once. A group-relative "
137
+ "baseline compares a completion against its own prompt's other "
138
+ "attempts, so K>1 is what produces an advantage at all — one attempt "
139
+ "per prompt has nothing to be relative to. Needs --temperature above "
140
+ "0, and is refused without it."
141
+ ),
142
+ )
143
+ evaluate.add_argument("--limit", type=_positive_int)
144
+ evaluate.add_argument("--program", help="program.json file or directory containing it")
145
+ evaluate.add_argument("--program-id")
146
+ evaluate.add_argument("--program-version")
147
+ evaluate.add_argument("--system-instructions")
148
+ evaluate.add_argument("--max-model-calls", type=_positive_int)
149
+ evaluate.add_argument("--max-tool-calls", type=_positive_int)
150
+ evaluate.add_argument("--task-timeout", type=_positive_float)
151
+ evaluate.add_argument("--model-timeout", type=_positive_float)
152
+ evaluate.add_argument("--tool-timeout", type=_positive_float)
153
+ evaluate.add_argument("--retries", type=_nonnegative_int)
154
+ evaluate.add_argument("--repair-calls", type=_nonnegative_int)
155
+ evaluate.add_argument("--token-budget", type=_positive_int)
156
+ evaluate.add_argument("--cost-budget", type=_positive_float)
157
+ evaluate.add_argument("--fail-fast", action="store_true")
158
+ evaluate.add_argument("--runtime", choices=("auto", "subprocess", "docker"), default="auto")
159
+ evaluate.add_argument("--runtime-image")
160
+ evaluate.add_argument("--runtime-network", choices=("public", "none"))
161
+ evaluate.add_argument("--runtime-cpu", type=_positive_float)
162
+ evaluate.add_argument("--runtime-memory-mb", type=_positive_int)
163
+ evaluate.add_argument("--runtime-pids", type=_positive_int)
164
+ evaluate.add_argument(
165
+ "--runtime-pull", choices=("missing", "always", "never"), default="missing"
166
+ )
167
+ evaluate.add_argument("--json", action="store_true", help="print the result as JSON")
168
+ evaluate.add_argument(
169
+ "--effort",
170
+ choices=[str(item) for item in ReasoningEffort],
171
+ default=None,
172
+ help="program-time budget dial. Sets model/tool call, wall-time, and token "
173
+ "allowances together. Any budget you pass explicitly is preserved. Effort "
174
+ "permits more reasoning; it does not promise a better score.",
175
+ )
176
+ evaluate.set_defaults(handler=_run_eval)
177
+
178
+ login = commands.add_parser(
179
+ "login",
180
+ help="sign in to your CoreThink account through the browser",
181
+ description="Authorize this CLI installation against your CoreThink account. "
182
+ "With no options it opens the browser you already sign in with; the CLI "
183
+ "never sees your Google credential. The options below configure endpoints "
184
+ "and a model provider's key instead, which is a separate thing and stays "
185
+ "separate.",
186
+ )
187
+ login.add_argument(
188
+ "--provider-key",
189
+ action="store_true",
190
+ help="prompt for a MODEL PROVIDER's key (OpenRouter and the like) and store "
191
+ "it for direct provider evaluation. Not a CoreThink credential and never "
192
+ "sent to CoreThink.",
193
+ )
194
+ login.add_argument(
195
+ "--api-key",
196
+ help=argparse.SUPPRESS, # the old, ambiguous spelling of --provider-key
197
+ )
198
+ login.add_argument("--api-url", help="the CoreThink API to sign in to")
199
+ login.add_argument(
200
+ "--no-browser",
201
+ action="store_true",
202
+ help="print the URL instead of opening it, for a machine with no browser",
203
+ )
204
+ login.add_argument("--base-url", help="model endpoint, used when --base-url is not passed")
205
+ login.add_argument("--model", help="default model")
206
+ login.add_argument("--registry-url", help="hosted program registry")
207
+ login.add_argument("--hub-url", help="environments hub")
208
+ login.add_argument(
209
+ "--hub-token",
210
+ action="store_true",
211
+ help="securely prompt for a temporary environment access token from Workbench",
212
+ )
213
+ login.set_defaults(handler=_run_login)
214
+
215
+ logout = commands.add_parser("logout", help="remove the stored credential")
216
+ logout.add_argument(
217
+ "--all",
218
+ action="store_true",
219
+ help="remove every stored setting, not only the credential",
220
+ )
221
+ logout.set_defaults(handler=_run_logout)
222
+
223
+ config = commands.add_parser("config", help="show or change stored settings")
224
+ config_commands = config.add_subparsers(dest="config_command", required=True)
225
+ config_list = config_commands.add_parser("list", help="every setting and where it came from")
226
+ config_list.add_argument("--json", action="store_true")
227
+ config_list.set_defaults(handler=_run_config_list)
228
+ config_get = config_commands.add_parser("get", help="one setting's value in force")
229
+ config_get.add_argument("key")
230
+ config_get.set_defaults(handler=_run_config_get)
231
+ config_set = config_commands.add_parser("set", help="store one setting")
232
+ config_set.add_argument("key")
233
+ config_set.add_argument("value")
234
+ config_set.set_defaults(handler=_run_config_set)
235
+
236
+ models = commands.add_parser("models", help="the endpoints this CLI can reach")
237
+ models_commands = models.add_subparsers(dest="models_command", required=True)
238
+ models_list = models_commands.add_parser("list", help="named endpoints and their readiness")
239
+ models_list.add_argument("--json", action="store_true")
240
+ models_list.set_defaults(handler=_run_models_list)
241
+
242
+ program = commands.add_parser("program", help="create portable reasoning programs")
243
+ program_commands = program.add_subparsers(dest="program_command", required=True)
244
+ initialize = program_commands.add_parser("init", help="create an editable program.json")
245
+ initialize.add_argument("path", help="target JSON file or directory")
246
+ initialize.add_argument("--program-id")
247
+ initialize.add_argument("--program-version", default=DEFAULT_PROGRAM_VERSION)
248
+ initialize.set_defaults(handler=_run_program_init)
249
+
250
+ from corethink import account
251
+
252
+ account.register(commands)
253
+
254
+ experiment_cli.register(commands, program_commands)
255
+ jobs_cli.register(commands)
256
+ from corethink import rl_cli
257
+
258
+ rl_cli.register(commands)
259
+
260
+ trace = commands.add_parser("trace", help="inspect evaluation artifacts")
261
+ trace_commands = trace.add_subparsers(dest="trace_command", required=True)
262
+ show = trace_commands.add_parser("show", help="show one completed run")
263
+ show.add_argument("run_id")
264
+ show.add_argument("--out", default=".corethink", help="artifact root directory")
265
+ show.add_argument("--json", action="store_true", help="include all events as JSON")
266
+ show.set_defaults(handler=_show_trace)
267
+
268
+ export = trace_commands.add_parser("export", help="export a redacted audit bundle for one run")
269
+ export.add_argument("run_id")
270
+ export.add_argument("--out", default=".", help="artifact root containing runs/")
271
+ export.add_argument("--to", default=None, help="write the bundle to this path")
272
+ export.add_argument("--request-id", default=None, help="production request this run answered")
273
+ export.add_argument("--received-at", default=None, help="when that request arrived")
274
+ export.add_argument("--subject-reference", default=None, help="opaque customer reference")
275
+ export.add_argument("--channel", default=None, help="how the request arrived")
276
+ export.add_argument(
277
+ "--all-events",
278
+ action="store_true",
279
+ help="include operational events outside the audit set",
280
+ )
281
+ export.add_argument("--json", action="store_true")
282
+ export.set_defaults(handler=_export_audit)
283
+ return parser
284
+
285
+
286
+ def main(argv: Sequence[str] | None = None) -> int:
287
+ parser = build_parser()
288
+ args = parser.parse_args(argv)
289
+ try:
290
+ return int(args.handler(args))
291
+ except (
292
+ ImportError,
293
+ OSError,
294
+ ValueError,
295
+ AccountError,
296
+ ConfigError,
297
+ ExperimentError,
298
+ JobsError,
299
+ ) as error:
300
+ print(f"corethink: configuration error: {error}", file=sys.stderr)
301
+ return 2
302
+ except Exception as error:
303
+ print(f"corethink: evaluation infrastructure failed: {error}", file=sys.stderr)
304
+ return 1
305
+
306
+
307
+ def _live_model_client(args: argparse.Namespace) -> OpenAICompatibleModelClient:
308
+ """The `--adapter live` transport, authenticated only when a key is configured.
309
+
310
+ The key comes from an environment variable, never an argument, so it never
311
+ lands in a shell history or process listing. Absent means unauthenticated —
312
+ correct for the public CoreThink Reasoner, and the reason this must never
313
+ default to requiring a key: that would break the one endpoint that needs none.
314
+ """
315
+
316
+ # The named environment variable first, because a caller who passed
317
+ # --model-api-key-env is naming where the key lives for this run. Then the
318
+ # stored credential, which is what `corethink login` exists to provide.
319
+ api_key = os.environ.get(args.model_api_key_env, "").strip() or (_provider_key() or "")
320
+ http_client = httpx.Client(headers={"Authorization": f"Bearer {api_key}"}) if api_key else None
321
+ return OpenAICompatibleModelClient(
322
+ _resolved(args, "base_url", DEFAULT_BASE_URL),
323
+ client=http_client,
324
+ capture_logprobs=getattr(args, "capture_logprobs", False),
325
+ )
326
+
327
+
328
+ def _provider_key() -> str | None:
329
+ """The model provider's key, with a broken config treated as unset.
330
+
331
+ Never the CoreThink account credential: that lives in its own file, has its
332
+ own prefix, and is refused by `corethink.account` if it ever turns up here.
333
+ """
334
+
335
+ from corethink.config import ConfigError, provider_key
336
+
337
+ try:
338
+ return provider_key()
339
+ except ConfigError:
340
+ return None
341
+
342
+
343
+ def _stored(key: str) -> str | None:
344
+ """One stored setting, with a missing or unreadable config treated as unset.
345
+
346
+ A broken config file must not stop an evaluation that was fully specified on
347
+ the command line; it is a convenience layer, and a convenience that can
348
+ prevent work is not one.
349
+ """
350
+
351
+ from corethink.config import ConfigError, resolve
352
+
353
+ try:
354
+ return resolve(key)
355
+ except ConfigError:
356
+ return None
357
+
358
+
359
+ def _resolved(args: argparse.Namespace, key: str, fallback: str | None = None) -> str | None:
360
+ """Flag, then environment variable, then stored config, then the built-in."""
361
+
362
+ return getattr(args, key, None) or _stored(key) or fallback
363
+
364
+
365
+ def _sampling_config(args: argparse.Namespace) -> dict:
366
+ """Sampling parameters, as part of the program's identity.
367
+
368
+ Only what was asked for is included, so an unset option leaves both the request
369
+ payload and the program hash exactly as earlier clients produced them.
370
+ """
371
+
372
+ config: dict = {"temperature": getattr(args, "temperature", 0) or 0}
373
+ top_p = getattr(args, "top_p", None)
374
+ if top_p is not None:
375
+ config["top_p"] = top_p
376
+ seed = getattr(args, "seed", None)
377
+ if seed is not None:
378
+ # A seed makes stochastic sampling reproducible, which sounds strictly good
379
+ # and is not: k samples of one prompt under one seed are k identical
380
+ # answers. Useful for reproducing a single rollout, useless for building a
381
+ # group, so it is opt-in and never defaulted.
382
+ config["seed"] = seed
383
+ return config
384
+
385
+
386
+ def _run_eval(args: argparse.Namespace) -> int:
387
+ if args.program and any(
388
+ value is not None
389
+ for value in (args.program_id, args.program_version, args.system_instructions)
390
+ ):
391
+ raise ValueError(
392
+ "--program cannot be combined with --program-id, --program-version, "
393
+ "or --system-instructions"
394
+ )
395
+ _apply_agent_preset(args)
396
+ group_size = getattr(args, "group", 1) or 1
397
+ if group_size > 1:
398
+ # The *effective* temperature, not the flag. `--program` supplies its own
399
+ # config, and a program pinned at temperature 0 made `--temperature 1.0`
400
+ # a no-op: the guard passed on the flag, every sample of a prompt was
401
+ # identical, and 70 tasks came back perfectly flat — which reads as a
402
+ # taskset with no headroom rather than as sampling that never varied.
403
+ temperature = _effective_temperature(args)
404
+ if temperature <= 0:
405
+ raise ValueError(
406
+ "--group requires --temperature above 0. At temperature 0 every "
407
+ "sample of a prompt is the same completion, so a group of K is K "
408
+ "copies of one answer: identical rewards, zero advantage, nothing "
409
+ "to learn from. Refused here rather than after K model calls have "
410
+ "been paid for."
411
+ )
412
+ if getattr(args, "seed", None) is not None:
413
+ raise ValueError(
414
+ "--group cannot be combined with --seed. One seed makes every "
415
+ "sample of a prompt identical, which is exactly what a group must "
416
+ "not be. Seed a single rollout to reproduce it; leave it unset to "
417
+ "collect a group."
418
+ )
419
+ loaded = load_taskset(args.taskset)
420
+ taskset = loaded.taskset
421
+ if args.limit is not None:
422
+ taskset = replace(taskset, tasks=taskset.tasks[: args.limit])
423
+ if not taskset.tasks:
424
+ raise ValueError("--limit selected no tasks")
425
+
426
+ if args.adapter == "fake":
427
+ if loaded.fake_client_factory is None:
428
+ raise ValueError(
429
+ f"{loaded.source} does not expose build_fake_client() required by --adapter fake"
430
+ )
431
+ client = loaded.fake_client_factory()
432
+ provider = "scripted"
433
+ model = args.model or "corethink/fake-tool-router"
434
+ else:
435
+ client = _live_model_client(args)
436
+ provider = "openai-compatible"
437
+ model = _resolved(args, "model", DEFAULT_MODEL)
438
+
439
+ try:
440
+ if args.program:
441
+ loaded_program = load_program_definition(args.program)
442
+ program = loaded_program.definition.bind(provider=provider, model=model)
443
+ program_source = ProgramSource.manifest(loaded_program.definition.content_hash)
444
+ program = _with_sampling_override(program, args)
445
+ else:
446
+ program = ProgramSpec(
447
+ program_id=args.program_id or DEFAULT_PROGRAM_ID,
448
+ version=args.program_version or DEFAULT_PROGRAM_VERSION,
449
+ provider=provider,
450
+ model=model,
451
+ system_instructions=args.system_instructions or DEFAULT_SYSTEM_INSTRUCTIONS,
452
+ # Temperature belongs in config, unlike logprob capture: it changes
453
+ # what the program *does*, so two temperatures are two programs and
454
+ # the hash should say so. The default stays 0, because every
455
+ # evaluation to date was deterministic and silently making them
456
+ # stochastic would make historical scores irreproducible.
457
+ #
458
+ # It has to be reachable, though. At temperature 0 every sample of
459
+ # a prompt is identical, so a GRPO group of k is k copies of one
460
+ # answer: flat, zero advantage, no gradient at all. Sampling k
461
+ # *different* completions is the entire premise of a group-relative
462
+ # baseline, and until now there was no way to ask for it.
463
+ config=_sampling_config(args),
464
+ )
465
+ program_source = ProgramSource.inline()
466
+ config = _run_config(args)
467
+ # Each attempt is a full run with its own artifacts, rather than K
468
+ # results inside one. The corpus builder already merges run directories
469
+ # and groups by task id, so this reuses the whole path instead of
470
+ # inventing a second artifact shape that only grouped runs would use.
471
+ results = [
472
+ EvaluationRunner(
473
+ taskset,
474
+ program,
475
+ client,
476
+ config=config,
477
+ artifact_root=Path(args.out),
478
+ program_source=program_source,
479
+ runtime_selection=_runtime_selection(args),
480
+ ).evaluate()
481
+ for _ in range(group_size)
482
+ ]
483
+ result = results[-1]
484
+ finally:
485
+ close = getattr(client, "close", None)
486
+ if callable(close):
487
+ close()
488
+
489
+ if args.json:
490
+ payload = (
491
+ result.to_dict() if group_size == 1 else {"group": [item.to_dict() for item in results]}
492
+ )
493
+ print(json.dumps(payload, indent=2, sort_keys=True))
494
+ elif group_size > 1:
495
+ _print_group(results)
496
+ else:
497
+ passed = sum(item.status is TaskStatus.PASSED for item in result.task_results)
498
+ aggregate = "null" if result.primary_score is None else f"{result.primary_score:.3f}"
499
+ print(
500
+ f"run {result.run_id}: score={aggregate} "
501
+ f"passed={passed}/{len(result.task_results)} "
502
+ f"scored={result.scored_task_count} errors={result.errored_task_count}"
503
+ )
504
+ for item in result.task_results:
505
+ score = "null" if item.score is None else f"{item.score.primary:.3f}"
506
+ print(
507
+ f" {item.task_id}: {item.status.value} "
508
+ f"score={score} stop={item.stop_reason.value} "
509
+ f"runtime={item.runtime_spec.provider if item.runtime_spec else 'unknown'}"
510
+ )
511
+ print(f"artifacts: {result.artifact_dir}")
512
+ return (
513
+ 1 if any(item.errored_task_count or item.infrastructure_warnings for item in results) else 0
514
+ )
515
+
516
+
517
+ def _apply_agent_preset(args: argparse.Namespace) -> None:
518
+ """Expand a named agent into the flags it stands for.
519
+
520
+ Applied before anything reads them, and only where the caller left a value
521
+ unset — naming a preset must never override something typed explicitly, or
522
+ `corethink eval tasks.py corethink --model other` would quietly run the
523
+ wrong model.
524
+ """
525
+
526
+ name = getattr(args, "agent", None)
527
+ if not name:
528
+ return
529
+ preset = AGENT_PRESETS.get(name)
530
+ if preset is None:
531
+ raise ValueError(
532
+ f"unknown agent {name!r}; known: {', '.join(sorted(AGENT_PRESETS))}. "
533
+ f"Pass --base-url and --model to reach anything else."
534
+ )
535
+ if preset.get("adapter") and args.adapter == "live":
536
+ args.adapter = preset["adapter"]
537
+ for key in ("base_url", "model"):
538
+ if preset.get(key) and not getattr(args, key, None):
539
+ setattr(args, key, preset[key])
540
+ # The key's *location* comes from the preset, never the key itself.
541
+ if preset.get("api_key_env") and args.model_api_key_env == DEFAULT_MODEL_API_KEY_ENV:
542
+ args.model_api_key_env = preset["api_key_env"]
543
+
544
+
545
+ def _effective_temperature(args: argparse.Namespace) -> float:
546
+ """The temperature the request will actually carry.
547
+
548
+ A flag on the command line, or — when a program file is named and the flag
549
+ is not — whatever that program pins. Read here rather than at the call site
550
+ so the group guard and the request cannot disagree about it.
551
+ """
552
+
553
+ flag = getattr(args, "temperature", None)
554
+ if flag is not None:
555
+ return float(flag)
556
+ if getattr(args, "program", None):
557
+ try:
558
+ definition = load_program_definition(args.program).definition
559
+ except (OSError, ValueError):
560
+ return 0.0
561
+ return float(definition.config.get("temperature", 0) or 0)
562
+ return 0.0
563
+
564
+
565
+ def _with_sampling_override(program, args: argparse.Namespace):
566
+ """Apply `--temperature`/`--top-p` over a program file's own config.
567
+
568
+ Sampling is part of a program's identity by design — two temperatures are
569
+ two programs and the hash says so — so this produces a *different* program
570
+ rather than the named one with a changed knob, and says so on stderr. The
571
+ alternative was silence, which is what let a group be collected at
572
+ temperature 0 while the caller had asked for 1.0.
573
+ """
574
+
575
+ overrides = {
576
+ key: getattr(args, key)
577
+ for key in ("temperature", "top_p")
578
+ if getattr(args, key, None) is not None
579
+ }
580
+ if not overrides:
581
+ return program
582
+ changed = {key: value for key, value in overrides.items() if program.config.get(key) != value}
583
+ if not changed:
584
+ return program
585
+ updated = replace(program, config={**program.config, **overrides})
586
+ print(
587
+ f"corethink: {args.program} pins "
588
+ + ", ".join(f"{key}={program.config.get(key)!r}" for key in changed)
589
+ + "; overridden to "
590
+ + ", ".join(f"{key}={value!r}" for key, value in changed.items())
591
+ + f". Sampling is part of a program's identity, so this is a different "
592
+ f"program: {updated.content_hash[:12]} rather than "
593
+ f"{program.content_hash[:12]}.",
594
+ file=sys.stderr,
595
+ )
596
+ return updated
597
+
598
+
599
+ def _print_group(results: list) -> None:
600
+ """What a group is collected for: whether any prompt's attempts disagree.
601
+
602
+ A task that *errored* is not a task that scored zero, and the two are easy
603
+ to confuse because the scorer still returns a `Score` for an errored task —
604
+ an empty answer matches nothing, so it reads 0.000. Counting those as wrong
605
+ answers invents disagreement out of a rate limit: eight correct completions
606
+ and four HTTP 429s read as a spread worth training on, which is exactly the
607
+ shape of failure this platform exists to refuse.
608
+
609
+ Errored attempts are therefore excluded from the numbers and reported on
610
+ their own, and a group missing attempts is called incomplete rather than
611
+ usable.
612
+ """
613
+
614
+ from statistics import fmean, pstdev
615
+
616
+ per_task: dict[str, list[float]] = {}
617
+ errored: dict[str, int] = {}
618
+ for result in results:
619
+ for item in result.task_results:
620
+ if getattr(item.status, "value", str(item.status)) == "error":
621
+ errored[item.task_id] = errored.get(item.task_id, 0) + 1
622
+ continue
623
+ if item.score is not None:
624
+ per_task.setdefault(item.task_id, []).append(item.score.primary)
625
+
626
+ attempts = len(results)
627
+ task_ids = sorted(set(per_task) | set(errored))
628
+ spread = [task for task, scores in per_task.items() if len(set(scores)) > 1]
629
+ print(f"group of {attempts} over {len(task_ids)} task(s)")
630
+ for task in task_ids:
631
+ scores = per_task.get(task, [])
632
+ failures = errored.get(task, 0)
633
+ if not scores:
634
+ print(f" {task}: no scored attempt — {failures}/{attempts} errored")
635
+ continue
636
+ mean = fmean(scores)
637
+ deviation = pstdev(scores) if len(scores) > 1 else 0.0
638
+ marker = "spread" if task in spread else "flat"
639
+ rendered = " ".join(f"{score:.3f}" for score in scores)
640
+ suffix = f" ({failures} errored, excluded)" if failures else ""
641
+ print(f" {task}: mean={mean:.3f} sd={deviation:.3f} {marker:<6} [{rendered}]{suffix}")
642
+
643
+ total_errors = sum(errored.values())
644
+ if total_errors:
645
+ # Before the verdict, because an incomplete group is the thing most
646
+ # likely to be mistaken for a result.
647
+ print(
648
+ f"{total_errors} attempt(s) errored and were excluded — "
649
+ f"`corethink trace show <run-id>` names the cause"
650
+ )
651
+ print(
652
+ "incomplete: some prompts have fewer attempts than others, so the "
653
+ "baseline they would be compared against is not the one you asked for"
654
+ )
655
+ return
656
+
657
+ verdict = (
658
+ "a group-relative baseline has something to work with"
659
+ if spread
660
+ else "nothing here can produce an advantage"
661
+ )
662
+ print(f"{len(spread)}/{len(task_ids)} task(s) produced a spread — {verdict}")
663
+
664
+
665
+ def _run_models_list(args: argparse.Namespace) -> int:
666
+ """Which endpoints are reachable, and which are missing a credential.
667
+
668
+ Deliberately not a catalogue of models a hosted service offers, because
669
+ there is no such service: these are the endpoints this CLI knows how to
670
+ address. Naming them here is the difference between "unknown agent" and
671
+ knowing what to type.
672
+
673
+ There is no `fork` to match HUD's trainable models. Creating one would be a
674
+ new word for machinery that already exists under a truer name, so the row
675
+ below points at it instead.
676
+ """
677
+
678
+ rows = []
679
+ for name in sorted(AGENT_PRESETS):
680
+ preset = AGENT_PRESETS[name]
681
+ key_env = preset.get("api_key_env")
682
+ if preset.get("adapter") == "fake":
683
+ readiness = "ready (offline)"
684
+ elif key_env is None:
685
+ readiness = "ready (no key needed)"
686
+ elif os.environ.get(key_env, "").strip():
687
+ readiness = f"ready (${key_env})"
688
+ elif _provider_key():
689
+ readiness = "ready (stored credential)"
690
+ else:
691
+ readiness = f"needs ${key_env} or `corethink login`"
692
+ rows.append(
693
+ {
694
+ "agent": name,
695
+ "model": preset.get("model", "—"),
696
+ "base_url": preset.get("base_url", "—"),
697
+ "readiness": readiness,
698
+ "description": preset.get("description", ""),
699
+ }
700
+ )
701
+
702
+ if args.json:
703
+ print(json.dumps(rows, indent=2, sort_keys=True))
704
+ return 0
705
+
706
+ print("named endpoints — use as `corethink eval <taskset> <agent>`")
707
+ for row in rows:
708
+ print(f" {row['agent']:<12} {row['model']:<34} {row['readiness']}")
709
+ print(f" {'':<12} {row['description']}")
710
+ stored_model = _stored("model")
711
+ if stored_model:
712
+ print(f" (config: model={stored_model}, base_url={_stored('base_url') or '—'})")
713
+ print(
714
+ " a trained adapter is not created here: `corethink jobs submit --train-on "
715
+ "corpus.jsonl` produces one, and `jobs adopt` makes it a governed candidate"
716
+ )
717
+ print(
718
+ " a free-tier model can be withdrawn without notice; if every task scores "
719
+ "0.000 flat, read the trace — a 404 is recorded as model_attempt_error"
720
+ )
721
+ return 0
722
+
723
+
724
+ def _run_login(args: argparse.Namespace) -> int:
725
+ """Sign in to a CoreThink account, or configure the things that are not one.
726
+
727
+ One command with two jobs, because `login` is the word people reach for and
728
+ splitting it would leave the old spelling doing nothing. The rule is stated
729
+ rather than guessed at: options mean "store this setting", no options means
730
+ "sign me in". What is gone is the old default — a bare `login` that prompted
731
+ for a credential without ever saying which service it belonged to.
732
+ """
733
+
734
+ from getpass import getpass
735
+
736
+ from corethink.account import AccountError, run_login
737
+ from corethink.config import CONFIG_KEYS, load_config, masked, save_config
738
+
739
+ values = load_config()
740
+ stored_any = False
741
+ provider_key = None
742
+ if args.api_key:
743
+ # Accepted, never silently: it used to mean the provider key, and a
744
+ # credential quietly filed under a new meaning is the failure this whole
745
+ # change exists to prevent.
746
+ print(
747
+ "note: --api-key means a model provider's key and is now --provider-key. "
748
+ "Storing it as provider_api_key; it is never sent to CoreThink.",
749
+ file=sys.stderr,
750
+ )
751
+ provider_key = args.api_key
752
+ elif args.provider_key:
753
+ provider_key = getpass(
754
+ "Model provider API key (input hidden, blank to keep the stored one): "
755
+ ).strip()
756
+ if provider_key:
757
+ values["provider_api_key"] = provider_key
758
+ stored_any = True
759
+ if args.hub_token:
760
+ hub_token = getpass("Temporary environment token (input hidden): ").strip()
761
+ if not hub_token:
762
+ raise ValueError("No environment token provided")
763
+ values["hub_token"] = hub_token
764
+ stored_any = True
765
+
766
+ for key in ("base_url", "model", "registry_url", "hub_url", "api_url"):
767
+ supplied = getattr(args, key, None)
768
+ if supplied:
769
+ values[key] = supplied
770
+ stored_any = True
771
+
772
+ if not stored_any:
773
+ try:
774
+ return run_login(args)
775
+ except AccountError as error:
776
+ raise ValueError(str(error)) from None
777
+
778
+ written = save_config(values)
779
+ print(f"stored: {written}")
780
+ for key in sorted(values):
781
+ if key in CONFIG_KEYS:
782
+ print(f" {key:<17} {masked(key, str(values[key]))}")
783
+ print(" (file mode 0600; no command prints a credential back in full)")
784
+ if provider_key:
785
+ print(" provider keys are used for direct provider evaluation only.")
786
+ print(" Run `corethink login` with no options to sign in to CoreThink.")
787
+ return 0
788
+
789
+
790
+ def _run_logout(args: argparse.Namespace) -> int:
791
+ """Sign out of CoreThink and remove the credentials stored on this machine.
792
+
793
+ Both halves, every time. Somebody typing `logout` wants this laptop to stop
794
+ being able to act as them, and leaving either the CoreThink session or the
795
+ provider key behind would not be that.
796
+ """
797
+
798
+ from corethink.account import AccountError, load_credential
799
+ from corethink.account import run_logout as account_logout
800
+ from corethink.config import config_path, load_config, save_config
801
+
802
+ if load_credential():
803
+ try:
804
+ account_logout(args)
805
+ except AccountError as error:
806
+ # The local credential still goes. A CLI that kept a usable token
807
+ # because it could not reach the network has not logged anybody out.
808
+ print(f"note: {error}", file=sys.stderr)
809
+
810
+ values = load_config()
811
+ if not values:
812
+ print(f"nothing stored at {config_path()}")
813
+ return 0
814
+ if args.all:
815
+ remaining: dict = {}
816
+ else:
817
+ remaining = {
818
+ key: value
819
+ for key, value in values.items()
820
+ if key not in {"api_key", "provider_api_key", "hub_token"}
821
+ }
822
+ save_config(remaining)
823
+ print(f"removed {'every setting' if args.all else 'stored credentials'} from {config_path()}")
824
+ return 0
825
+
826
+
827
+ def _run_config_list(args: argparse.Namespace) -> int:
828
+ from corethink.config import CONFIG_KEYS, config_path, describe, masked
829
+
830
+ settings = describe()
831
+ if args.json:
832
+ print(
833
+ json.dumps(
834
+ {
835
+ setting.key: {
836
+ "value": None
837
+ if setting.value is None
838
+ else masked(setting.key, setting.value),
839
+ "source": setting.source,
840
+ }
841
+ for setting in settings
842
+ },
843
+ indent=2,
844
+ sort_keys=True,
845
+ )
846
+ )
847
+ return 0
848
+ print(f"config: {config_path()}")
849
+ for setting in settings:
850
+ shown = "—" if setting.value is None else masked(setting.key, setting.value)
851
+ # The source is the useful column: a stale environment variable beating
852
+ # the file someone just edited is the confusion this exists to end.
853
+ print(f" {setting.key:<13} {shown:<28} {setting.source}")
854
+ print(f" ({len(CONFIG_KEYS)} settings; an environment variable always wins over the file)")
855
+ return 0
856
+
857
+
858
+ def _run_config_get(args: argparse.Namespace) -> int:
859
+ from corethink.config import resolve
860
+
861
+ value = resolve(args.key)
862
+ if value is None:
863
+ print(f"{args.key} is unset", file=sys.stderr)
864
+ return 1
865
+ # Printed in full, unmasked, because this is the command whose entire job is
866
+ # to hand the value to something else.
867
+ print(value)
868
+ return 0
869
+
870
+
871
+ def _run_config_set(args: argparse.Namespace) -> int:
872
+ from corethink.config import CONFIG_KEYS, load_config, masked, save_config
873
+
874
+ if args.key not in CONFIG_KEYS:
875
+ raise ValueError(f"unknown setting {args.key!r}; known: {', '.join(sorted(CONFIG_KEYS))}")
876
+ values = load_config()
877
+ values[args.key] = args.value
878
+ written = save_config(values)
879
+ print(f"{args.key} = {masked(args.key, args.value)} ({written})")
880
+ return 0
881
+
882
+
883
+ def _run_program_init(args: argparse.Namespace) -> int:
884
+ loaded = initialize_program_definition(
885
+ args.path,
886
+ program_id=args.program_id,
887
+ version=args.program_version,
888
+ )
889
+ print(f"created program: {loaded.source}")
890
+ print(f"next: corethink eval <taskset> --program {loaded.source}")
891
+ return 0
892
+
893
+
894
+ def _show_trace(args: argparse.Namespace) -> int:
895
+ reader = ArtifactReader(args.out, args.run_id)
896
+ manifest = reader.manifest()
897
+ result = reader.result()
898
+ events = reader.events()
899
+ if args.json:
900
+ print(
901
+ json.dumps(
902
+ {
903
+ "manifest": manifest,
904
+ "result": result.to_dict(),
905
+ "events": [event.to_dict() for event in events],
906
+ },
907
+ indent=2,
908
+ sort_keys=True,
909
+ )
910
+ )
911
+ return 0
912
+
913
+ requested = manifest.get("requested_program", {})
914
+ print(f"run {result.run_id}: taskset={result.taskset_id}@{result.taskset_version}")
915
+ print(
916
+ "requested program: "
917
+ f"{requested.get('program_id')}@{requested.get('version')} "
918
+ f"model={requested.get('model')} sha256={requested.get('content_hash')}"
919
+ )
920
+ print(f"events: {len(events)} usage: {json.dumps(result.usage.to_dict(), sort_keys=True)}")
921
+ print("timeline:")
922
+ for event in events:
923
+ duration = f" {event.duration_ms:.1f}ms" if event.duration_ms is not None else ""
924
+ print(f" [{event.sequence:04d}] {event.task_id} {event.kind}{duration}")
925
+ for item in result.task_results:
926
+ identities = [identity.to_dict() for identity in item.observed_identities]
927
+ print(f"\n{item.task_id}: {item.status.value} / {item.stop_reason.value}")
928
+ score = item.score.to_dict() if item.score else None
929
+ print(f" score: {json.dumps(score, sort_keys=True)}")
930
+ print(f" final state: {json.dumps(item.final_state.to_dict(), sort_keys=True)}")
931
+ print(f" observed engine: {json.dumps(identities, sort_keys=True)}")
932
+ requested_runtime = item.runtime_spec.to_dict() if item.runtime_spec else None
933
+ observed_runtime = item.runtime_info.to_dict() if item.runtime_info else None
934
+ print(
935
+ " runtime: "
936
+ f"requested={json.dumps(requested_runtime, sort_keys=True)} "
937
+ f"observed={json.dumps(observed_runtime, sort_keys=True)}"
938
+ )
939
+ print(f" cleanup: ok={item.cleanup_ok}")
940
+ return 0
941
+
942
+
943
+ def _export_audit(args: argparse.Namespace) -> int:
944
+ reader = ArtifactReader(args.out, args.run_id)
945
+ correlation = None
946
+ if args.request_id:
947
+ correlation = RequestCorrelation(
948
+ request_id=args.request_id,
949
+ received_at=args.received_at or _iso_now(),
950
+ subject_reference=args.subject_reference,
951
+ channel=args.channel,
952
+ )
953
+ bundle = build_audit_bundle(
954
+ reader,
955
+ run_id=args.run_id,
956
+ correlation=correlation,
957
+ include_all_events=args.all_events,
958
+ )
959
+ if args.to:
960
+ path = write_audit_bundle(bundle, Path(args.to))
961
+ if args.json:
962
+ print(json.dumps({"path": str(path), "content_hash": bundle.content_hash}, indent=2))
963
+ else:
964
+ print(f"wrote audit bundle: {path}")
965
+ print(f"digest: {bundle.content_hash}")
966
+ for note in bundle.omissions:
967
+ print(f"omission: {note}")
968
+ return 0
969
+
970
+ if args.json:
971
+ print(json.dumps(bundle.to_dict(), indent=2, sort_keys=True))
972
+ else:
973
+ print(f"run: {bundle.run_id}")
974
+ print(f"digest: {bundle.content_hash}")
975
+ if bundle.correlation:
976
+ print(f"request: {bundle.correlation.request_id}")
977
+ print(f"events: {len(bundle.events)}")
978
+ for note in bundle.omissions:
979
+ print(f"omission: {note}")
980
+ return 0
981
+
982
+
983
+ def _iso_now() -> str:
984
+ import datetime as _dt
985
+
986
+ return _dt.datetime.now(tz=_dt.UTC).isoformat().replace("+00:00", "Z")
987
+
988
+
989
+ def _run_config(args: argparse.Namespace) -> RunConfig:
990
+ overrides = {
991
+ "max_model_calls": args.max_model_calls,
992
+ "max_tool_calls": args.max_tool_calls,
993
+ "task_timeout_seconds": args.task_timeout,
994
+ "model_timeout_seconds": args.model_timeout,
995
+ "tool_timeout_seconds": args.tool_timeout,
996
+ "transient_retries": args.retries,
997
+ "malformed_repair_calls": args.repair_calls,
998
+ "token_budget": args.token_budget,
999
+ "cost_budget": args.cost_budget,
1000
+ "fail_fast": args.fail_fast,
1001
+ }
1002
+ explicit = {name: value for name, value in overrides.items() if value is not None}
1003
+ config = RunConfig(**explicit)
1004
+
1005
+ effort = getattr(args, "effort", None)
1006
+ if effort is None:
1007
+ return config
1008
+
1009
+ # Pin only the dial-controlled budgets the caller actually set. Store-true
1010
+ # flags like --fail-fast are always present in `explicit` and are none of the
1011
+ # dial's business, so they must not be offered as pinned fields.
1012
+ pinned = tuple(name for name in explicit if name in DIAL_FIELDS)
1013
+ resolution = resolve_effort(effort, base=config, pinned=pinned)
1014
+ if not getattr(args, "json", False):
1015
+ print(f"effort: {resolution.effort}")
1016
+ if resolution.caller_pinned:
1017
+ print(f" preserved: {', '.join(resolution.caller_pinned)}")
1018
+ print(f" {resolution.notice}")
1019
+ return resolution.config
1020
+
1021
+
1022
+ def _runtime_selection(args: argparse.Namespace) -> RuntimeSelection:
1023
+ return RuntimeSelection(
1024
+ provider=args.runtime,
1025
+ image=args.runtime_image,
1026
+ network=NetworkMode(args.runtime_network) if args.runtime_network else None,
1027
+ resources=RuntimeResources(
1028
+ cpu=args.runtime_cpu,
1029
+ memory_mb=args.runtime_memory_mb,
1030
+ pids=args.runtime_pids,
1031
+ ),
1032
+ pull_policy=PullPolicy(args.runtime_pull),
1033
+ )
1034
+
1035
+
1036
+ def _positive_int(value: str) -> int:
1037
+ parsed = int(value)
1038
+ if parsed <= 0:
1039
+ raise argparse.ArgumentTypeError("must be positive")
1040
+ return parsed
1041
+
1042
+
1043
+ def _nonnegative_int(value: str) -> int:
1044
+ parsed = int(value)
1045
+ if parsed < 0:
1046
+ raise argparse.ArgumentTypeError("must be nonnegative")
1047
+ return parsed
1048
+
1049
+
1050
+ def _positive_float(value: str) -> float:
1051
+ parsed = float(value)
1052
+ if parsed <= 0:
1053
+ raise argparse.ArgumentTypeError("must be positive")
1054
+ return parsed
1055
+
1056
+
1057
+ if __name__ == "__main__":
1058
+ raise SystemExit(main())