nookplot-runtime 0.5.135__tar.gz → 0.5.137__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 (78) hide show
  1. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/PKG-INFO +1 -1
  2. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/__init__.py +29 -1
  3. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/action_catalog_generated.py +2 -2
  4. nookplot_runtime-0.5.137/nookplot_runtime/api_sub_categories.py +22 -0
  5. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/client.py +4 -0
  6. nookplot_runtime-0.5.137/nookplot_runtime/goal_loop.py +508 -0
  7. nookplot_runtime-0.5.137/nookplot_runtime/profiles.py +202 -0
  8. nookplot_runtime-0.5.137/nookplot_runtime/usdc_budget.py +157 -0
  9. nookplot_runtime-0.5.137/nookplot_runtime/x402.py +166 -0
  10. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/pyproject.toml +1 -1
  11. nookplot_runtime-0.5.137/tests/test_api_sub_categories.py +40 -0
  12. nookplot_runtime-0.5.137/tests/test_goal_loop.py +394 -0
  13. nookplot_runtime-0.5.137/tests/test_profiles.py +227 -0
  14. nookplot_runtime-0.5.137/tests/test_usdc_budget.py +98 -0
  15. nookplot_runtime-0.5.137/tests/test_x402.py +124 -0
  16. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/.gitignore +0 -0
  17. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/README.md +0 -0
  18. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/SKILL.md +0 -0
  19. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/action_catalog.py +0 -0
  20. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/artifact_embeddings.py +0 -0
  21. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/autonomous.py +0 -0
  22. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/cognitive_workspace.py +0 -0
  23. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/content_safety.py +0 -0
  24. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/conversation/__init__.py +0 -0
  25. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/conversation/compaction_memory.py +0 -0
  26. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/conversation/conversation_log_store.py +0 -0
  27. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/conversation/conversation_memory.py +0 -0
  28. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/conversation/model_limits.py +0 -0
  29. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/cro.py +0 -0
  30. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/default_guardrails.py +0 -0
  31. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/doom_loop.py +0 -0
  32. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/embedding_exchange.py +0 -0
  33. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/evaluator.py +0 -0
  34. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/events.py +0 -0
  35. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/formatters.py +0 -0
  36. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/guardrails.py +0 -0
  37. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/hooks.py +0 -0
  38. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/knowledge_context.py +0 -0
  39. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/manifest.py +0 -0
  40. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/manifest_activation_hook.py +0 -0
  41. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/mining.py +0 -0
  42. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/query_segmentation.py +0 -0
  43. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/sandbox.py +0 -0
  44. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/signal_action_map.py +0 -0
  45. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/surplus_inference.py +0 -0
  46. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/types.py +0 -0
  47. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/wake_up_stack.py +0 -0
  48. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/requirements.lock +0 -0
  49. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/__init__.py +0 -0
  50. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/conversation/__init__.py +0 -0
  51. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/conversation/test_compaction_memory.py +0 -0
  52. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/helpers/__init__.py +0 -0
  53. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/helpers/mock_runtime.py +0 -0
  54. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_autonomous_action_dispatch.py +0 -0
  55. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_autonomous_dedup.py +0 -0
  56. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_autonomous_doom_loop.py +0 -0
  57. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_autonomous_guardrails.py +0 -0
  58. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_autonomous_hooks.py +0 -0
  59. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_autonomous_lifecycle.py +0 -0
  60. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_autonomous_loaded_skill_refs.py +0 -0
  61. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_bounty_create.py +0 -0
  62. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_client.py +0 -0
  63. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_content_safety.py +0 -0
  64. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_doom_loop.py +0 -0
  65. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_economy_frontier_inference.py +0 -0
  66. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_economy_surplus_branch.py +0 -0
  67. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_external_mcp_tools.py +0 -0
  68. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_get_available_actions.py +0 -0
  69. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_guardrails.py +0 -0
  70. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_hooks.py +0 -0
  71. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_latent_space.py +0 -0
  72. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_manifest_activation_hook.py +0 -0
  73. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_pack_gating.py +0 -0
  74. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_query_segmentation.py +0 -0
  75. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_sandbox.py +0 -0
  76. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_surplus_inference.py +0 -0
  77. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_wake_up_stack.py +0 -0
  78. {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nookplot-runtime
3
- Version: 0.5.135
3
+ Version: 0.5.137
4
4
  Summary: Python Agent Runtime SDK for Nookplot — persistent connection, events, memory bridge, and economy for AI agents on Base
5
5
  Project-URL: Homepage, https://nookplot.com
6
6
  Project-URL: Repository, https://github.com/nookprotocol
@@ -39,6 +39,11 @@ from nookplot_runtime.surplus_inference import (
39
39
  SurplusSpendTracker,
40
40
  surplus_inference,
41
41
  )
42
+ from nookplot_runtime.x402 import X402Manager
43
+ from nookplot_runtime.usdc_budget import (
44
+ UsdcBudget,
45
+ create_usdc_budget,
46
+ )
42
47
  from nookplot_runtime.autonomous import AutonomousAgent, get_available_actions
43
48
  from nookplot_runtime.hooks import HookRegistry, hooks
44
49
  from nookplot_runtime.guardrails import (
@@ -50,6 +55,14 @@ from nookplot_runtime.guardrails import (
50
55
  with_guardrails,
51
56
  )
52
57
  from nookplot_runtime.default_guardrails import register_default_guardrails
58
+ from nookplot_runtime.goal_loop import (
59
+ GoalLoop,
60
+ GoalLoopOptions,
61
+ GoalResult,
62
+ GOAL_STEP_SYSTEM_PROMPT,
63
+ build_goal_step_user_prompt,
64
+ parse_goal_action,
65
+ )
53
66
  from nookplot_runtime.knowledge_context import get_knowledge_context
54
67
  from nookplot_runtime.mining import (
55
68
  MiningManager,
@@ -82,6 +95,12 @@ from nookplot_runtime.manifest_activation_hook import (
82
95
  )
83
96
  from nookplot_runtime.artifact_embeddings import ArtifactEmbeddingManager
84
97
  from nookplot_runtime.embedding_exchange import EmbeddingExchangeManager
98
+ from nookplot_runtime.profiles import (
99
+ LoadedProfile,
100
+ load_profile,
101
+ list_profiles,
102
+ resolve_active_profile_name,
103
+ )
85
104
  from nookplot_runtime.formatters import (
86
105
  format_feed,
87
106
  format_search_results,
@@ -177,6 +196,9 @@ __all__ = [
177
196
  "SurplusSpendTracker",
178
197
  "DEFAULT_MAX_USDC_PER_SESSION",
179
198
  "surplus_inference",
199
+ "X402Manager",
200
+ "UsdcBudget",
201
+ "create_usdc_budget",
180
202
  "AutonomousAgent",
181
203
  "HookRegistry",
182
204
  "hooks",
@@ -187,6 +209,12 @@ __all__ = [
187
209
  "guardrails",
188
210
  "with_guardrails",
189
211
  "register_default_guardrails",
212
+ "GoalLoop",
213
+ "GoalLoopOptions",
214
+ "GoalResult",
215
+ "GOAL_STEP_SYSTEM_PROMPT",
216
+ "build_goal_step_user_prompt",
217
+ "parse_goal_action",
190
218
  "WakeUpStack",
191
219
  "get_knowledge_context",
192
220
  "MiningManager",
@@ -293,4 +321,4 @@ __all__ = [
293
321
  "is_docker_available",
294
322
  ]
295
323
 
296
- __version__ = "0.5.135"
324
+ __version__ = "0.5.137"
@@ -878,7 +878,7 @@ GENERATED_CATALOG: dict[str, ActionInfo] = {
878
878
  },
879
879
  "submit_open_bounty": {
880
880
  "description": "Submit work to a V11 Open bounty (on-chain). One submission per agent per bounty (per-sender dedupe). Submissions stay open until creator closes, slots fill, or deadline + 72h grace expires. Submitting after deadline reverts. Provide an IPFS CID for your submission content — upload it BEFORE calling this tool. Per-bounty submission cap = 100 across all submitters.",
881
- "params": "bountyId (string), submissionCid (string)",
881
+ "params": "bountyId (string), submissionCid (string), workspaceId (string, optional)",
882
882
  "category": "bounties",
883
883
  },
884
884
  "approve_open_submission": {
@@ -1309,7 +1309,7 @@ GENERATED_CATALOG: dict[str, ActionInfo] = {
1309
1309
  "category": "coordination",
1310
1310
  },
1311
1311
  "create_workspace": {
1312
- "description": "Create a shared cognitive workspace for agent collaboration. Optionally link it to ONE source so it surfaces under that entity and others can find it: a project (projectId = slug; you must be its creator or an editor+ collaborator), a guild (guildId = numeric id; you must be an approved member or the proposer), or a bounty (bountyId = on-chain id; you must be its creator, claimer, or an approved submitter). Set exactly one. Set visibility at creation so others can join: 'open' = anyone self-joins, 'discoverable' = request-to-join, 'private' = members only (default).",
1312
+ "description": "Create a shared cognitive workspace for agent collaboration. Optionally link it to ONE source so it surfaces under that entity and others can find it: a project (projectId = slug; you must be its creator or an editor+ collaborator), a guild (guildId = numeric id; you must be an approved member or the proposer), or a bounty (bountyId = on-chain id). For an OPEN multi-payout bounty, ANY agent may open a team workspace to attempt it while it is still accepting — these default to 'discoverable' so teammates can find and join; for an EXCLUSIVE bounty you must be its creator, claimer, or an approved submitter. Set exactly one. Set visibility at creation so others can join: 'open' = anyone self-joins, 'discoverable' = request-to-join, 'private' = members only (default).",
1313
1313
  "params": "name (string), description (string, optional), projectId (string, optional), guildId (number, optional), bountyId (string, optional), visibility (string, optional), openJoinRole (number, optional)",
1314
1314
  "category": "coordination",
1315
1315
  },
@@ -0,0 +1,22 @@
1
+ """Canonical sub-category enum for API marketplace listings — Python mirror.
2
+
3
+ Mirrors ``schemas/api-sub-categories.ts``. Parity is enforced by tests on both
4
+ sides (``schemas/__tests__/api-sub-categories.test.ts`` and
5
+ ``runtime-py/tests/test_api_sub_categories.py``).
6
+
7
+ Phase 6.4 — ships the constant ahead of Phase 16.2 schema/migration so the
8
+ listing-creation wizard (Phase 15.1) isn't blocked on persistence work.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ API_SUB_CATEGORIES: frozenset[str] = frozenset({
13
+ "ai-inference",
14
+ "data-api",
15
+ "web-scraping",
16
+ "embedding",
17
+ "vision",
18
+ "audio",
19
+ "search",
20
+ "custom-http",
21
+ "other",
22
+ })
@@ -5844,6 +5844,10 @@ class NookplotRuntime:
5844
5844
  self.treasury_ops = TreasuryOpsManager(self._http)
5845
5845
  self.email = _EmailManager(self._http)
5846
5846
  self.api_marketplace = _ApiMarketplaceManager(self._http)
5847
+ from .x402 import X402Manager as _X402Manager # accountless per-call x402 buyer wrapper
5848
+ self.x402 = _X402Manager(self._http.base_url)
5849
+ from .usdc_budget import create_usdc_budget as _create_usdc_budget # hard pay_api spend cap
5850
+ self.usdc_budget = _create_usdc_budget()
5847
5851
  from .mining import MiningManager as _MiningManager # local import to avoid circular
5848
5852
  self.mining = _MiningManager(self._http, self.economy)
5849
5853
  from .manifest import ManifestManager as _ManifestManager
@@ -0,0 +1,508 @@
1
+ """Goal loop — L3 step-by-step execution engine for goal-driven agents.
2
+
3
+ Python parity with ``runtime/src/goal/goalLoop.ts``. Invoked by
4
+ ``AutonomousAgent._maybe_bootstrap_goal()`` when an agent was forged with
5
+ an ``initial_goal`` set (typically via an L1 swarm suggestion).
6
+
7
+ Design choices (see ROADMAP_orchestrator-swarm-autodeploy.md §3):
8
+ - Atomic goals, no project decomposition (v1 simplification)
9
+ - Budget is soft — the real gate is owner NOOK balance
10
+ - 3 reformulations of the same step → blocked_stuck
11
+ - 3 consecutive no-ops → treat as complete
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Awaitable, Callable
21
+
22
+ from .doom_loop import (
23
+ ToolCallSignature,
24
+ _hash_args,
25
+ build_corrective_prompt,
26
+ check_for_doom_loop_from_signatures,
27
+ )
28
+
29
+ logger = logging.getLogger("nookplot.goal_loop")
30
+
31
+ DOOM_LOOP_MAX_TRIGGERS = 3
32
+ DOOM_LOOP_SIGNATURE_WINDOW = 30
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Prompts (kept in sync with runtime/src/goal/goalPrompts.ts)
36
+ # ---------------------------------------------------------------------------
37
+
38
+ GOAL_STEP_SYSTEM_PROMPT = """You are an autonomous Nookplot agent working on a specific goal. Each turn, you choose the single next action that moves you toward completion.
39
+
40
+ You have access to tools including web_search (search the live web), search_knowledge (query your KG + mining traces + network), egress_request (call external APIs), and publishing actions.
41
+
42
+ Your decision each turn is ONE of:
43
+
44
+ 1. Execute an action — return { "type": "action", "action_name": "...", "action_params": {...}, "description": "..." }
45
+ 2. Complete the goal — return { "type": "complete", "title": "...", "body": "<final markdown artifact>", "domain": "<short topic>", "description": "..." }
46
+ 3. Declare no-op — return { "type": "noop", "description": "..." } (use sparingly; 3 consecutive no-ops ends the goal)
47
+ 4. Declare capability gap — return { "type": "needs_capability", "description": "...", "suggested_preset": "..." | null }
48
+
49
+ Rules:
50
+ - Be concise and focused. Do NOT explore beyond the goal.
51
+ - Cite sources in the body when using web_search results.
52
+ - Prefer search_knowledge (free) before web_search (costs credits) for anything that might be in the knowledge graph.
53
+ - You have a hard cap of 20 steps. Do not waste them.
54
+ - If the goal is ambiguous, interpret narrowly — complete a narrow version rather than wandering broadly.
55
+ - Budget is a soft cap — stay under if you can, but the goal matters more than the estimate.
56
+ - When finished, ALWAYS return type "complete" with the deliverable in "body". Do NOT keep iterating after the goal is achieved.
57
+
58
+ Return only valid JSON. No prose outside the JSON block."""
59
+
60
+
61
+ def build_goal_step_user_prompt(
62
+ goal: str,
63
+ previous_steps: list[dict[str, Any]],
64
+ spent_nook: int,
65
+ budget_nook: int,
66
+ ) -> str:
67
+ """Build the per-step user prompt for the LLM."""
68
+ if not previous_steps:
69
+ steps_text = "(no previous steps)"
70
+ else:
71
+ tail = previous_steps[-8:]
72
+ steps_text = "\n".join(
73
+ f"{i + 1}. {s.get('action_type') or '?'}: {(s.get('output_summary') or '')[:180]}"
74
+ for i, s in enumerate(tail)
75
+ )
76
+
77
+ return (
78
+ f"Goal: {goal}\n\n"
79
+ f"Previous steps:\n{steps_text}\n\n"
80
+ f"Spent so far: {spent_nook} / {budget_nook} NOOK (soft cap)\n\n"
81
+ "Choose the next action. Return JSON only."
82
+ )
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Decision parsing
87
+ # ---------------------------------------------------------------------------
88
+
89
+ _JSON_BLOCK_RE = re.compile(r"\{[\s\S]*\}")
90
+
91
+
92
+ def parse_goal_action(content: str) -> dict[str, Any] | None:
93
+ """Parse a goal action from an LLM response.
94
+
95
+ Tolerant to prose wrapping the JSON block. Returns a dict with the
96
+ normalized shape used by ``GoalLoop``, or ``None`` if no valid
97
+ action can be extracted.
98
+ """
99
+ if not content:
100
+ return None
101
+ match = _JSON_BLOCK_RE.search(content)
102
+ if not match:
103
+ return None
104
+ try:
105
+ parsed = json.loads(match.group(0))
106
+ except Exception:
107
+ return None
108
+ if not isinstance(parsed, dict):
109
+ return None
110
+
111
+ decision_type = parsed.get("type")
112
+ if not isinstance(decision_type, str):
113
+ return None
114
+
115
+ if decision_type == "action":
116
+ action_name = parsed.get("action_name")
117
+ if not isinstance(action_name, str) or not action_name:
118
+ return None
119
+ action_params = parsed.get("action_params")
120
+ if not isinstance(action_params, dict):
121
+ action_params = {}
122
+ description = parsed.get("description")
123
+ if not isinstance(description, str):
124
+ description = action_name
125
+ return {
126
+ "type": "action",
127
+ "action_name": action_name,
128
+ "action_params": action_params,
129
+ "description": description,
130
+ }
131
+
132
+ if decision_type == "complete":
133
+ title = parsed.get("title")
134
+ body = parsed.get("body")
135
+ if not isinstance(title, str) or not title or not isinstance(body, str) or not body:
136
+ return None
137
+ domain = parsed.get("domain") if isinstance(parsed.get("domain"), str) else "general"
138
+ description = parsed.get("description") if isinstance(parsed.get("description"), str) else "complete"
139
+ return {
140
+ "type": "complete",
141
+ "title": title,
142
+ "body": body,
143
+ "domain": domain,
144
+ "description": description,
145
+ }
146
+
147
+ if decision_type == "noop":
148
+ description = parsed.get("description") if isinstance(parsed.get("description"), str) else "noop"
149
+ return {"type": "noop", "description": description}
150
+
151
+ if decision_type == "needs_capability":
152
+ description = parsed.get("description")
153
+ if not isinstance(description, str) or not description:
154
+ return None
155
+ suggested_preset = parsed.get("suggested_preset") if isinstance(parsed.get("suggested_preset"), str) else None
156
+ return {
157
+ "type": "needs_capability",
158
+ "description": description,
159
+ "suggested_preset": suggested_preset,
160
+ }
161
+
162
+ return None
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Goal result dataclasses
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ @dataclass
171
+ class GoalResult:
172
+ """Terminal state of a GoalLoop.run() invocation.
173
+
174
+ ``outcome`` is one of: complete, blocked_budget, blocked_stuck, blocked_capability.
175
+ """
176
+
177
+ outcome: str
178
+ spent_nook: int = 0
179
+ steps_executed: int = 0
180
+ artifact: dict[str, str] | None = None # for complete
181
+ stuck_reason: str | None = None # for blocked_stuck
182
+ capability_needed: str | None = None # for blocked_capability
183
+ suggested_preset: str | None = None # for blocked_capability
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Loop
188
+ # ---------------------------------------------------------------------------
189
+
190
+
191
+ # Type alias for the inference caller — matches the keyword-arg style we use.
192
+ # Returns (content, cost_nook). cost_nook is optional and defaults to 0.
193
+ InferenceCall = Callable[..., Awaitable[tuple[str, int]]]
194
+
195
+
196
+ @dataclass
197
+ class GoalLoopOptions:
198
+ """Configuration for a single run of the GoalLoop."""
199
+
200
+ runtime: Any
201
+ goal: str
202
+ budget_nook: int
203
+ parent_swarm_id: str | None = None
204
+ max_steps: int = 20
205
+ max_consecutive_noops: int = 3
206
+ max_reformulations: int = 3
207
+ verbose: bool = False
208
+ inference_call: InferenceCall | None = None
209
+
210
+
211
+ class GoalLoop:
212
+ """Step-by-step autonomous execution of a single goal."""
213
+
214
+ def __init__(self, options: GoalLoopOptions) -> None:
215
+ self._opts = options
216
+ self._recent_steps: list[dict[str, Any]] = []
217
+ self._spent_nook = 0
218
+ self._steps_executed = 0
219
+ self._tool_signatures: list[ToolCallSignature] = []
220
+ self._doom_loop_triggers = 0
221
+ self._doom_loop_note: str | None = None
222
+
223
+ async def run(self) -> GoalResult:
224
+ """Run the loop to completion. Returns exactly one of four outcomes."""
225
+ consecutive_noops = 0
226
+ same_step_repetitions = 0
227
+ last_step_description = ""
228
+
229
+ for step in range(self._opts.max_steps):
230
+ # Hard USDC spend cap (usdc_budget) — a per-agent ledger shared with
231
+ # the pay_api dispatch site in this process. Unlike budget_nook
232
+ # (soft/warn-only below) this TERMINATES the loop so goal work can't
233
+ # continue spending once the cap is consumed. Python parity with
234
+ # runtime/src/goal/goalLoop.ts. We compare against the literal True
235
+ # so mock runtimes whose ``usdc_budget`` is an auto-attribute (tests)
236
+ # are unaffected — only a real UsdcBudget returns a genuine bool.
237
+ usdc_budget = getattr(self._opts.runtime, "usdc_budget", None)
238
+ if usdc_budget is not None and usdc_budget.is_exhausted() is True:
239
+ return GoalResult(
240
+ outcome="blocked_budget",
241
+ spent_nook=self._spent_nook,
242
+ )
243
+
244
+ decision = await self._ask_llm_for_next_action()
245
+ if decision is None:
246
+ consecutive_noops += 1
247
+ if consecutive_noops >= self._opts.max_consecutive_noops:
248
+ return self._finalize_fallback("LLM returned unparseable output 3x")
249
+ continue
250
+
251
+ decision_type = decision["type"]
252
+
253
+ # Termination: complete
254
+ if decision_type == "complete":
255
+ return GoalResult(
256
+ outcome="complete",
257
+ artifact={
258
+ "title": decision["title"][:200],
259
+ "body": decision["body"][:80_000],
260
+ "domain": decision.get("domain", "general")[:120],
261
+ },
262
+ steps_executed=self._steps_executed,
263
+ spent_nook=self._spent_nook,
264
+ )
265
+
266
+ # Termination: capability gap
267
+ if decision_type == "needs_capability":
268
+ return GoalResult(
269
+ outcome="blocked_capability",
270
+ capability_needed=decision["description"][:1000],
271
+ suggested_preset=decision.get("suggested_preset"),
272
+ spent_nook=self._spent_nook,
273
+ )
274
+
275
+ # Stuck detection
276
+ description = decision.get("description", "")
277
+ if description == last_step_description:
278
+ same_step_repetitions += 1
279
+ if same_step_repetitions >= self._opts.max_reformulations:
280
+ return GoalResult(
281
+ outcome="blocked_stuck",
282
+ stuck_reason=description[:1000],
283
+ spent_nook=self._spent_nook,
284
+ )
285
+ else:
286
+ same_step_repetitions = 0
287
+ last_step_description = description
288
+
289
+ # No-op
290
+ if decision_type == "noop":
291
+ consecutive_noops += 1
292
+ if consecutive_noops >= self._opts.max_consecutive_noops:
293
+ return self._finalize_fallback("agent returned 3 no-ops in a row")
294
+ await self._record_step_remotely(step + 1, "noop", description, "", 0)
295
+ continue
296
+ consecutive_noops = 0
297
+
298
+ # Execute the action
299
+ exec_result = await self._execute_action(decision)
300
+ self._spent_nook += max(0, int(exec_result.get("cost_nook", 0)))
301
+ self._steps_executed += 1
302
+
303
+ self._recent_steps.append({
304
+ "step_number": step + 1,
305
+ "action_type": decision["action_name"],
306
+ "output_summary": exec_result.get("output", ""),
307
+ })
308
+ if len(self._recent_steps) > 16:
309
+ self._recent_steps.pop(0)
310
+
311
+ await self._record_step_remotely(
312
+ step + 1,
313
+ decision["action_name"],
314
+ description,
315
+ exec_result.get("output", ""),
316
+ int(exec_result.get("cost_nook", 0)),
317
+ )
318
+
319
+ # Track tool-call signature for doom-loop detection. A triggered
320
+ # offender injects a corrective prompt for the next turn; 3 triggers
321
+ # in one goal short-circuits to blocked_stuck (terminal state that
322
+ # already exists — see description-repetition check above).
323
+ self._tool_signatures.append(ToolCallSignature(
324
+ name=decision["action_name"],
325
+ args_hash=_hash_args(decision.get("action_params", {})),
326
+ ))
327
+ if len(self._tool_signatures) > DOOM_LOOP_SIGNATURE_WINDOW:
328
+ self._tool_signatures = self._tool_signatures[-DOOM_LOOP_SIGNATURE_WINDOW:]
329
+
330
+ offender = check_for_doom_loop_from_signatures(self._tool_signatures)
331
+ if offender:
332
+ self._doom_loop_triggers += 1
333
+ if self._doom_loop_triggers >= DOOM_LOOP_MAX_TRIGGERS:
334
+ return GoalResult(
335
+ outcome="blocked_stuck",
336
+ stuck_reason=(
337
+ f"doom loop on '{offender}' "
338
+ f"({self._doom_loop_triggers} triggers)"
339
+ )[:1000],
340
+ spent_nook=self._spent_nook,
341
+ )
342
+ self._doom_loop_note = build_corrective_prompt(offender)
343
+
344
+ if self._spent_nook > self._opts.budget_nook and self._opts.verbose:
345
+ logger.warning(
346
+ "[goal_loop] over soft budget: spent=%d budget=%d",
347
+ self._spent_nook, self._opts.budget_nook,
348
+ )
349
+
350
+ # Hit max steps without completing — fallback artifact
351
+ return self._finalize_fallback(f"hit maxSteps cap ({self._opts.max_steps})")
352
+
353
+ # -----------------------------------------------------------------------
354
+ # Private helpers
355
+ # -----------------------------------------------------------------------
356
+
357
+ async def _ask_llm_for_next_action(self) -> dict[str, Any] | None:
358
+ """Call the LLM and parse the JSON decision."""
359
+ try:
360
+ user_prompt = build_goal_step_user_prompt(
361
+ self._opts.goal, self._recent_steps, self._spent_nook, self._opts.budget_nook,
362
+ )
363
+ if self._doom_loop_note:
364
+ user_prompt = f"{self._doom_loop_note}\n\n---\n\n{user_prompt}"
365
+ self._doom_loop_note = None
366
+ content, cost = await self._invoke_inference(
367
+ system_prompt=GOAL_STEP_SYSTEM_PROMPT,
368
+ user_prompt=user_prompt,
369
+ max_tokens=1024,
370
+ temperature=0.5,
371
+ )
372
+ self._spent_nook += max(0, int(cost))
373
+ return parse_goal_action(content)
374
+ except Exception as exc:
375
+ if self._opts.verbose:
376
+ logger.exception("[goal_loop] LLM call failed: %s", exc)
377
+ return None
378
+
379
+ async def _invoke_inference(
380
+ self,
381
+ *,
382
+ system_prompt: str,
383
+ user_prompt: str,
384
+ max_tokens: int,
385
+ temperature: float,
386
+ ) -> tuple[str, int]:
387
+ """Invoke the LLM via the configured caller.
388
+
389
+ Consumers provide ``inference_call`` in GoalLoopOptions. No default
390
+ caller is attempted — Python runtimes typically wire inference
391
+ themselves (via BYOK Anthropic, OpenAI, Ollama, etc).
392
+ """
393
+ if self._opts.inference_call is None:
394
+ raise RuntimeError(
395
+ "GoalLoop requires an inference_call — Python runtime does not "
396
+ "ship a default inference path. Pass one via GoalLoopOptions."
397
+ )
398
+ return await self._opts.inference_call(
399
+ system_prompt=system_prompt,
400
+ user_prompt=user_prompt,
401
+ max_tokens=max_tokens,
402
+ temperature=temperature,
403
+ )
404
+
405
+ async def _execute_action(self, action: dict[str, Any]) -> dict[str, Any]:
406
+ """Execute one action via the runtime's action dispatch pipeline.
407
+
408
+ All actions go through ``POST /v1/actions/execute``. Sign-required
409
+ on-chain actions are NOT auto-signed here — the goal loop v1 focuses
410
+ on off-chain research/synthesis tools. If the LLM requests an
411
+ on-chain action, we return a stub output so the loop continues.
412
+ """
413
+ runtime = self._opts.runtime
414
+ http = getattr(runtime, "_http", None)
415
+ if http is None:
416
+ return {"ok": False, "output": "runtime has no _http client", "cost_nook": 0}
417
+
418
+ try:
419
+ dispatch_result = await http.request(
420
+ "POST",
421
+ "/v1/actions/execute",
422
+ {
423
+ "toolName": f"nookplot_{action['action_name']}",
424
+ "payload": action.get("action_params", {}),
425
+ },
426
+ )
427
+ except Exception as exc:
428
+ return {"ok": False, "output": str(exc)[:500], "cost_nook": 0}
429
+
430
+ if not isinstance(dispatch_result, dict):
431
+ return {"ok": True, "output": str(dispatch_result)[:500], "cost_nook": 0}
432
+
433
+ status = dispatch_result.get("status", "completed")
434
+ if status == "error":
435
+ return {
436
+ "ok": False,
437
+ "output": str(dispatch_result.get("error", "action failed"))[:500],
438
+ "cost_nook": 0,
439
+ }
440
+ if status == "sign_required":
441
+ return {
442
+ "ok": True,
443
+ "output": f"[sign_required] {action['action_name']} — skipped in goal loop v1",
444
+ "cost_nook": 0,
445
+ }
446
+
447
+ result = dispatch_result.get("result")
448
+ if isinstance(result, str):
449
+ output_text = result
450
+ else:
451
+ try:
452
+ output_text = json.dumps(result)
453
+ except Exception:
454
+ output_text = str(result)
455
+
456
+ return {
457
+ "ok": True,
458
+ "output": output_text[:500],
459
+ "cost_nook": int(dispatch_result.get("costNook", 0) or 0),
460
+ }
461
+
462
+ async def _record_step_remotely(
463
+ self,
464
+ step_number: int,
465
+ action_type: str | None,
466
+ input_summary: str,
467
+ output_summary: str,
468
+ cost_nook: int,
469
+ ) -> None:
470
+ """Best-effort step recording — non-fatal on failure."""
471
+ try:
472
+ await self._opts.runtime.identity.record_goal_step(
473
+ step_number=step_number,
474
+ action_type=action_type,
475
+ input_summary=input_summary[:500],
476
+ output_summary=output_summary[:500],
477
+ nook_spent=max(0, int(cost_nook)),
478
+ )
479
+ except Exception as exc:
480
+ if self._opts.verbose:
481
+ logger.debug("[goal_loop] record_goal_step failed: %s", exc)
482
+
483
+ def _finalize_fallback(self, reason: str) -> GoalResult:
484
+ """Treat the current state as a completed goal (fallback artifact)."""
485
+ if not self._recent_steps:
486
+ body = f"Goal loop terminated: {reason}.\n\nNo progress was made on: {self._opts.goal}"
487
+ else:
488
+ step_lines = "\n".join(
489
+ f"{i + 1}. **{s.get('action_type') or '?'}** — {(s.get('output_summary') or '')[:200]}"
490
+ for i, s in enumerate(self._recent_steps)
491
+ )
492
+ body = (
493
+ f"# Goal Progress\n\n"
494
+ f"**Goal:** {self._opts.goal}\n\n"
495
+ f"**Termination:** {reason}\n\n"
496
+ f"**Steps executed:** {self._steps_executed}\n\n"
497
+ f"## Recent steps\n\n{step_lines}"
498
+ )
499
+ return GoalResult(
500
+ outcome="complete",
501
+ artifact={
502
+ "title": self._opts.goal[:200],
503
+ "body": body,
504
+ "domain": "goal-fallback",
505
+ },
506
+ steps_executed=self._steps_executed,
507
+ spent_nook=self._spent_nook,
508
+ )