@topolo/cli 0.13.17 → 0.13.19

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.
package/README.md CHANGED
@@ -537,10 +537,24 @@ exchanges the stored refresh token for a new pair before making the request
537
537
  and rewrites the config — no user action required. The OAuth `client_id` used
538
538
  at login is persisted alongside the credential so refresh can re-authenticate.
539
539
 
540
- If refresh fails (network, rotated/revoked refresh token), the CLI proceeds
541
- with the stale credential and the request returns `auth_error` — run
542
- `topolo auth login` to re-authenticate. The stored config is not cleared on
543
- transient refresh failures.
540
+ If refresh fails for a transient reason (network, a 5xx, a rate limit), the
541
+ saved grant is kept and the request fails with a retryable error; a rate limit
542
+ reports `rate_limited` with `retryAfter`, and the CLI never retries a refresh
543
+ on its own.
544
+
545
+ If Auth refuses the grant (`invalid_grant` or `expired_token`), the saved
546
+ sign-in has ended. It is removed once, under the config lock, and is never sent
547
+ again by any process, so a dead sign-in cannot use up the token endpoint's
548
+ failure limit for everything else on the machine. One shared device
549
+ re-authorization is started for that environment and context, and then:
550
+
551
+ - **At a terminal**, the CLI opens the approval page, prints its URL and code,
552
+ waits for approval and continues the command with the new credential.
553
+ - **Anywhere else** (no terminal, `CI` set, or `TOPOLO_NO_REAUTH_PROMPT=1`), the
554
+ command exits with code 2 and a structured `reauth_required` error that
555
+ carries the approval link, user code, expiry and the equivalent
556
+ `topolo auth login` command. The device code is never printed. Approve the
557
+ link (or run the login command) and the next command continues.
544
558
 
545
559
  ## Topolo Agent Packs
546
560
 
@@ -722,3 +722,51 @@ Only existing owned physical destinations inside the library can change. Shared
722
722
  Apply reserves `launchers/adoption-<UUID>.json` exclusively. The journal starts in `may-have-config-effects`, records each verified replacement, and becomes `configuration-prepared-native-adoption-unverified` only after all readbacks. Every write rechecks generation, source, profile metadata, preservation pins and destination custody. An interrupted or conflicting apply retains the journal and any uncertain temporary file; it never automatically replays or rolls back. Reconcile exact effects with the original owner.
723
723
 
724
724
  No apply result proves native reload, identity, current RBAC, model/effort, loaded context, original-intake permission routing or a native tool result. Hosts may react to configuration changes; admit the actual affected owner/generation and normal request settlement before applying in a live host. Later ordinary startup can renew the private credential and load identity. A previous 429/403 or scoped recovery hold remains separately applicable and elapsed time grants no retry. Actual Codex and Claude same-query context, permission, nonce result, cancellation/settlement and own retirement require independent native evidence.
725
+
726
+ ## Throughput and adaptive reasoning
727
+
728
+ New task-model templates use revision 2. Codex chooses Luna/low only for an
729
+ explicit `trivial`, low-risk `format`, `extract-fields`, `reference-lookup` or
730
+ `status-read` task. Routine tasks use Sol/low (with medium as a declared effort
731
+ alternative); standard tasks use Sol/medium; complex and security-critical
732
+ tasks use Sol/high. Astra remains available only through an authorized explicit
733
+ override. Unavailable models/efforts refuse rather than silently falling back.
734
+ Existing installed configurations and fixed native sessions are not migrated by
735
+ upgrading the adapter. Review and install a current `models-template`, then verify
736
+ the actual native model/effort acknowledgement for every task. `low` is the native
737
+ name for light reasoning.
738
+
739
+ `topolo agent-packs lane allocate --input snapshot.json` returns a bounded,
740
+ read-only allocation plan. The closed version-1 input contains `now` (epoch
741
+ seconds), `maxAgeSeconds` (1–3600), actual execution `slots` (0–32),
742
+ `maxNewLanes` (0–8), `tasks`, `lanes`, `completed`, `configuration` and `catalog`.
743
+ The configuration/catalog use the existing task-model contracts. Each task has
744
+ `id`, `capability`, `priority` (0 is highest), `owner` (lane ID or null),
745
+ `dependencies` (completed task IDs), `authorized`, `paused` and `modelTask`.
746
+ Each lane has `id`, `capabilities`, `observedAt`, `registryState`
747
+ (idle/busy/held/paused/unknown), `nativeState` (idle/busy/unknown), `activeTask`
748
+ (ID or null), `retainedTasks`, `reusableCheckpoint`, `assignments`,
749
+ `lastAssigned` and its explicit model `constraints`.
750
+
751
+ The allocator considers every compatible worker rather than preferring an
752
+ ordinal. It chooses the least assigned, then oldest assigned worker; never
753
+ moves an owned task; permits retained custody only at an explicit idle safe
754
+ checkpoint; isolates stale/contradictory observations and blocked dependencies;
755
+ and limits both existing work and proposed new lanes by supplied execution
756
+ capacity. A worker receives at most one assignment per plan. The caller must
757
+ supply trustworthy current observations; a timestamp in JSON does not prove
758
+ native availability or admission.
759
+
760
+ The result's snapshot digest must be checked by the canonical claim writer
761
+ before an exclusive assignment. Each assignment includes its full model
762
+ resolution with `nativeACK: null`. Create requests are proposals requiring the
763
+ normal registered native lifecycle, not created agents or claims. This command
764
+ does not claim, dispatch, wake, schedule, execute or retire anything. Native
765
+ settings, authority, actual delivery and completion remain independently
766
+ verified boundaries. In particular, it does not repair or establish a hidden
767
+ cross-chat event transport by writing a file.
768
+
769
+ Fresh-role `config.json` model/effort presets are retained in the captured agent
770
+ manifest and used when compiling its Codex runtime policy. They do not revert
771
+ to a legacy high-effort role default on activation. A missing preset keeps the
772
+ legacy profile unchanged. Native catalog and exact settings readback still apply.
@@ -15,7 +15,7 @@ from coverage.parser import PythonParser
15
15
 
16
16
  RELEASE_BASE = '6a13ada56844712aae58738ba01dbbdf90718bef'
17
17
  SOURCE = pathlib.Path('agent-packs/src/topolo_agent_pack')
18
- MODULES = {'topolo', 'claude', 'policy', 'permissions', 'approvals', 'execution', 'live', 'cli', 'lanes', 'task_models'}
18
+ MODULES = {'topolo', 'claude', 'policy', 'permissions', 'approvals', 'execution', 'live', 'cli', 'lanes', 'task_models', 'throughput'}
19
19
 
20
20
 
21
21
  class CoverageRefused(RuntimeError):
@@ -331,7 +331,7 @@ def parser():
331
331
  q.add_argument('--connection');q.add_argument('--intake');q.add_argument('--fingerprint');q.add_argument('--response')
332
332
  q.add_argument('--human-decision-recorded',action='store_true')
333
333
  q=sub.add_parser('lane',help='Plan, adopt and verify one pack-managed native child through its original intake')
334
- q.add_argument('operation',choices=['plan','adopt','configure','status','read','settle','retire','serve','close','create','context-start','context-read','fixture-start','fixture-read','models-template','models-install'])
334
+ q.add_argument('operation',choices=['allocate','plan','adopt','configure','status','read','settle','retire','serve','close','create','context-start','context-read','fixture-start','fixture-read','models-template','models-install'])
335
335
  q.add_argument('--connection',help='Exact explicit foreground owned lane connection ID')
336
336
  q.add_argument('--input',help='Closed operation JSON; never arbitrary native RPC or task prompts')
337
337
  q=sub.add_parser('smoke',help='Start or read one closed, bound native acceptance case; never arbitrary prompts')
@@ -411,7 +411,12 @@ def main(argv=None):
411
411
  params=read_json(args.input) if args.input else {}
412
412
  if not isinstance(params,dict):
413
413
  raise PackError('Closed native lane input object required')
414
- if args.operation in {'models-template','models-install'}:
414
+ if args.operation=='allocate':
415
+ from .throughput import allocate
416
+ if not isinstance(params.get('catalog'),dict) or params['catalog'].get('harness')!=harness:
417
+ raise PackError('Allocation catalog must match the selected native harness')
418
+ result=allocate(params)
419
+ elif args.operation in {'models-template','models-install'}:
415
420
  from . import lanes
416
421
  if args.operation=='models-template':
417
422
  if set(params)!={'pack'}:
@@ -62,7 +62,7 @@ class ExecutionGate:
62
62
  required = {'model', 'model_reasoning_effort', 'approval_policy', 'approvals_reviewer',
63
63
  'sandbox_mode', 'sandbox_workspace_write', 'apps'}
64
64
  if (set(spec) != required or not isinstance(spec['model'], str) or not spec['model'] or
65
- (spec['model_reasoning_effort'] not in {'medium', 'high'} and not
65
+ (spec['model_reasoning_effort'] not in {'low', 'medium', 'high'} and not
66
66
  (role.get('dynamicLaneId') and role.get('dynamicModelResolution', {}).get('resolved') ==
67
67
  {'model': spec['model'], 'effort': spec['model_reasoning_effort']})) or
68
68
  spec['approval_policy'] not in {'on-request', 'never'} or spec['approvals_reviewer'] != 'user' or
@@ -122,7 +122,7 @@ def create(lib,source,name,directory,binary,sets=None,agents=None,authored=None)
122
122
  if preset:b.call('thread/settings/update',{'threadId':tid,'model':preset['model'],'effort':preset['reasoningEffort']})
123
123
  note='Fresh '+a['title']+'. Read AGENTS.md and relevant context/ files for operating instructions. No task is assigned. Wait for a new human request.'
124
124
  b.call('thread/inject_items',{'threadId':tid,'items':[{'type':'message','role':'user','content':[{'type':'input_text','text':note}]}]});b.call('thread/unsubscribe',{'threadId':tid})
125
- seeded.append({**a,'pack':name,'threadId':tid});records.append((tid,result['thread']['path'],note))
125
+ seeded.append({**a,'pack':name,'threadId':tid,**({'modelPreset':preset} if preset else {})});records.append((tid,result['thread']['path'],note))
126
126
  team='# Fresh pack team\n\nResolve peer identities here for human-authorized coordination. Every member starts with no task or claim. This roster grants no external messaging or product effects.\n\n'+'\n'.join('- '+a['set']+'/'+a['id']+' | '+a['title']+' | thread '+a['threadId'] for a in seeded)+'\n'
127
127
  for a in seeded:
128
128
  target=root/'workspaces'/a['set']/a['id']/'context'/'TEAM.md';target.parent.mkdir(parents=True,exist_ok=True);target.write_text(team)
@@ -59,6 +59,14 @@ def settings(agent, harness, consent=None):
59
59
  spec = PROFILES[profile]
60
60
  model = MODELS[harness][spec['tier']]
61
61
  if harness == 'codex':
62
+ if 'modelPreset' in agent:
63
+ preset = agent['modelPreset']
64
+ if (not isinstance(preset, dict) or set(preset) != {'model', 'reasoningEffort'} or
65
+ not isinstance(preset['model'], str) or not preset['model'] or
66
+ preset['model'].endswith('-latest') or preset['reasoningEffort'] not in {'low', 'medium', 'high'}):
67
+ raise PackError('Invalid explicit fresh-role model preset')
68
+ model = preset['model']
69
+ spec = {**spec, 'effort': preset['reasoningEffort']}
62
70
  native = {'model': model, 'model_reasoning_effort': spec['effort'],
63
71
  'approval_policy': 'on-request', 'approvals_reviewer': 'user',
64
72
  'sandbox_mode': spec['localAccess'],
@@ -16,23 +16,27 @@ def digest(value):
16
16
  def default_configuration():
17
17
  """Explicit template for a new pack configuration; existing roles are unchanged."""
18
18
  return {
19
- 'format': 'topolo-task-model-configuration', 'schemaVersion': 1, 'revision': '1',
19
+ 'format': 'topolo-task-model-configuration', 'schemaVersion': 1, 'revision': '2',
20
20
  'rules': [
21
- {'id': 'routine', 'complexity': ['routine'], 'risk': ['low'], 'profile': 'routine'},
21
+ {'id': 'trivial', 'complexity': ['trivial'], 'risk': ['low'], 'profile': 'trivial',
22
+ 'types': ['format', 'extract-fields', 'reference-lookup', 'status-read']},
23
+ {'id': 'routine', 'complexity': ['routine'], 'risk': ['low', 'normal'], 'profile': 'routine'},
22
24
  {'id': 'deep', 'complexity': ['complex'], 'risk': ['low', 'normal', 'security-critical'], 'profile': 'deep'},
23
25
  {'id': 'security', 'complexity': ['routine', 'standard'], 'risk': ['security-critical'], 'profile': 'deep'},
24
26
  {'id': 'standard', 'complexity': ['standard'], 'risk': ['low', 'normal'], 'profile': 'standard'},
25
27
  ],
26
28
  'harnesses': {
27
29
  'codex': {
28
- 'tiers': {'standard': 'gpt-6.1-sol', 'deep': 'gpt-6-astra'},
29
- 'profiles': {'routine': {'tier': 'standard', 'efforts': ['low', 'medium']},
30
+ 'tiers': {'standard': 'gpt-6.1-sol', 'deep': 'gpt-6-astra', 'economy': 'gpt-6-luna'},
31
+ 'profiles': {'trivial': {'tier': 'economy', 'efforts': ['low']},
32
+ 'routine': {'tier': 'standard', 'efforts': ['low', 'medium']},
30
33
  'standard': {'tier': 'standard', 'efforts': ['medium']},
31
- 'deep': {'tier': 'deep', 'efforts': ['high']}},
34
+ 'deep': {'tier': 'standard', 'efforts': ['high']}},
32
35
  },
33
36
  'claude': {
34
37
  'tiers': {'economy': 'claude-haiku-5-5', 'standard': 'claude-sonnet-5-5', 'deep': 'claude-opus-5-5'},
35
- 'profiles': {'routine': {'tier': 'economy', 'efforts': ['low', 'medium']},
38
+ 'profiles': {'trivial': {'tier': 'economy', 'efforts': ['low']},
39
+ 'routine': {'tier': 'standard', 'efforts': ['low', 'medium']},
36
40
  'standard': {'tier': 'standard', 'efforts': ['medium']},
37
41
  'deep': {'tier': 'deep', 'efforts': ['high']}},
38
42
  },
@@ -80,7 +84,7 @@ def _selection(configuration, task, harness, constraints, *, override_authorized
80
84
  if (not isinstance(task, dict) or not {'type', 'complexity', 'risk'} <= set(task) or
81
85
  set(task) - {'type', 'complexity', 'risk', 'override'} or
82
86
  not isinstance(task['type'], str) or not task['type'] or len(task['type']) > 120 or
83
- task['complexity'] not in {'routine', 'standard', 'complex'} or
87
+ task['complexity'] not in {'trivial', 'routine', 'standard', 'complex'} or
84
88
  task['risk'] not in {'low', 'normal', 'security-critical'}):
85
89
  raise PackError('An intake must supply an exact task type, complexity and risk')
86
90
  rules = configuration.get('rules')
@@ -0,0 +1,149 @@
1
+ """Bounded allocation decisions, without claim writes, wakeups or ownership changes.
2
+
3
+ Callers supply current canonical and native observations, then atomically claim
4
+ against snapshotDigest before dispatch. A plan is never evidence of execution.
5
+ """
6
+ from .storage import PackError
7
+ from .task_models import digest, resolve
8
+
9
+
10
+ def _require(condition, message):
11
+ if not condition:
12
+ raise PackError(message)
13
+
14
+
15
+ def _keys(value, fields):
16
+ _require(isinstance(value, dict) and set(value) == set(fields.split()),
17
+ 'Allocation input requires exact fields: ' + fields)
18
+
19
+
20
+ def _text(value):
21
+ return isinstance(value, str) and 0 < len(value) <= 240
22
+
23
+
24
+ def _integer(value, minimum, maximum):
25
+ return type(value) is int and minimum <= value <= maximum
26
+
27
+
28
+ def _names(value):
29
+ return (isinstance(value, list) and len(value) <= 256 and
30
+ all(_text(item) for item in value) and len(value) == len(set(value)))
31
+
32
+
33
+ def allocate(snapshot):
34
+ """Choose compatible idle workers fairly; isolate stale/blocked work.
35
+
36
+ Observations are supplied evidence, not a native status probe. Existing
37
+ owners stay pinned. Retained custody is reusable only at an explicit safe
38
+ checkpoint with the actual native worker idle. Resource slots are separate
39
+ from chat availability and apply to both existing and proposed workers.
40
+ """
41
+ _keys(snapshot, 'schemaVersion now maxAgeSeconds slots maxNewLanes tasks lanes completed configuration catalog')
42
+ _require(snapshot['schemaVersion'] == 1 and
43
+ _integer(snapshot['now'], 1, 2**53) and
44
+ _integer(snapshot['maxAgeSeconds'], 1, 3600) and
45
+ _integer(snapshot['slots'], 0, 32) and
46
+ _integer(snapshot['maxNewLanes'], 0, 8) and
47
+ _names(snapshot['completed']), 'Invalid allocation bounds')
48
+ tasks, lanes = snapshot['tasks'], snapshot['lanes']
49
+ _require(isinstance(tasks, list) and len(tasks) <= 256 and
50
+ isinstance(lanes, list) and len(lanes) <= 256, 'Bounded task and lane lists required')
51
+ task_ids, lane_ids = set(), set()
52
+ for task in tasks:
53
+ _keys(task, 'id capability priority owner dependencies authorized paused modelTask')
54
+ _require(_text(task['id']) and task['id'] not in task_ids and
55
+ _text(task['capability']) and _integer(task['priority'], 0, 9) and
56
+ (task['owner'] is None or _text(task['owner'])) and
57
+ _names(task['dependencies']) and task['id'] not in task['dependencies'] and
58
+ type(task['authorized']) is bool and type(task['paused']) is bool,
59
+ 'Malformed or duplicate allocation task')
60
+ task_ids.add(task['id'])
61
+ for lane in lanes:
62
+ _keys(lane, 'id capabilities observedAt registryState nativeState activeTask retainedTasks reusableCheckpoint assignments lastAssigned constraints')
63
+ _require(_text(lane['id']) and lane['id'] not in lane_ids and
64
+ _names(lane['capabilities']) and _names(lane['retainedTasks']) and
65
+ _integer(lane['observedAt'], 1, 2**53) and
66
+ lane['registryState'] in {'idle', 'busy', 'held', 'paused', 'unknown'} and
67
+ lane['nativeState'] in {'idle', 'busy', 'unknown'} and
68
+ (lane['activeTask'] is None or _text(lane['activeTask'])) and
69
+ type(lane['reusableCheckpoint']) is bool and
70
+ _integer(lane['assignments'], 0, 2**53) and
71
+ _integer(lane['lastAssigned'], 0, 2**53), 'Malformed or duplicate allocation lane')
72
+ lane_ids.add(lane['id'])
73
+ completed = set(snapshot['completed'])
74
+ _require(not completed.intersection(task_ids), 'Completed tasks must not remain in the ready queue')
75
+ result = {'format': 'topolo-allocation-plan', 'schemaVersion': 1,
76
+ 'snapshotDigest': digest(snapshot), 'assignments': [], 'createRequests': [],
77
+ 'blocked': [], 'laneIssues': [], 'effectsStarted': False,
78
+ 'executionRequirements': ['current exclusive claim against snapshotDigest',
79
+ 'native model and effort acknowledgement',
80
+ 'verified authorized task delivery and result readback']}
81
+ eligible = []
82
+ for lane in lanes:
83
+ age = snapshot['now'] - lane['observedAt']
84
+ issue = None
85
+ if age < 0 or age > snapshot['maxAgeSeconds']:
86
+ issue = 'stale-observation'
87
+ elif lane['registryState'] in {'paused', 'unknown'} or lane['nativeState'] == 'unknown':
88
+ issue = 'paused-or-unknown'
89
+ elif lane['activeTask'] is not None or lane['nativeState'] == 'busy':
90
+ issue = 'occupied'
91
+ elif lane['registryState'] == 'busy':
92
+ issue = 'reconcile-registry-with-idle-native'
93
+ elif lane['retainedTasks'] or lane['registryState'] == 'held':
94
+ if not lane['reusableCheckpoint']:
95
+ issue = 'custody-checkpoint-required'
96
+ if issue:
97
+ result['laneIssues'].append({'laneId': lane['id'], 'reason': issue})
98
+ else:
99
+ eligible.append(lane)
100
+ slots = snapshot['slots']
101
+ for task in sorted(tasks, key=lambda row: (row['priority'], row['id'])):
102
+ reason = None
103
+ if task['paused']:
104
+ reason = 'task-paused'
105
+ elif not task['authorized']:
106
+ reason = 'task-unauthorized'
107
+ elif not set(task['dependencies']).issubset(completed):
108
+ reason = 'dependencies-pending'
109
+ elif not slots:
110
+ reason = 'execution-capacity-exhausted'
111
+ if reason:
112
+ result['blocked'].append({'taskId': task['id'], 'reason': reason})
113
+ continue
114
+ compatible = [lane for lane in lanes if task['capability'] in lane['capabilities'] and
115
+ (task['owner'] is None or lane['id'] == task['owner'])]
116
+ candidates = sorted((lane for lane in compatible if lane in eligible),
117
+ key=lambda row: (row['assignments'], row['lastAssigned'], row['id']))
118
+ failures = []
119
+ for lane in candidates:
120
+ try:
121
+ resolution = resolve(snapshot['configuration'], task['modelTask'],
122
+ snapshot['catalog'].get('harness'), snapshot['catalog'], lane['constraints'])
123
+ except (PackError, AttributeError) as error:
124
+ failures.append({'laneId': lane['id'], 'reason': str(error)})
125
+ continue
126
+ result['assignments'].append({'taskId': task['id'], 'laneId': lane['id'],
127
+ 'resolution': resolution,
128
+ 'retainedTasks': list(lane['retainedTasks'])})
129
+ eligible.remove(lane)
130
+ slots -= 1
131
+ break
132
+ else:
133
+ uncertain = any(issue['laneId'] in {lane['id'] for lane in compatible} and
134
+ issue['reason'] in {'stale-observation', 'paused-or-unknown',
135
+ 'reconcile-registry-with-idle-native'}
136
+ for issue in result['laneIssues'])
137
+ if (task['owner'] is None and not candidates and not uncertain and
138
+ len(result['createRequests']) < snapshot['maxNewLanes']):
139
+ result['createRequests'].append({'taskId': task['id'], 'capability': task['capability'],
140
+ 'reason': 'no-compatible-free-lane',
141
+ 'requires': 'registered native lane and verified task model before claim'})
142
+ slots -= 1
143
+ else:
144
+ result['blocked'].append({'taskId': task['id'],
145
+ 'reason': ('model-unavailable' if failures else
146
+ 'lane-state-reconciliation-required' if uncertain else
147
+ 'owner-or-capability-unavailable'),
148
+ 'modelFailures': failures})
149
+ return result