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.
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/PKG-INFO +1 -1
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/__init__.py +29 -1
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/action_catalog_generated.py +2 -2
- nookplot_runtime-0.5.137/nookplot_runtime/api_sub_categories.py +22 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/client.py +4 -0
- nookplot_runtime-0.5.137/nookplot_runtime/goal_loop.py +508 -0
- nookplot_runtime-0.5.137/nookplot_runtime/profiles.py +202 -0
- nookplot_runtime-0.5.137/nookplot_runtime/usdc_budget.py +157 -0
- nookplot_runtime-0.5.137/nookplot_runtime/x402.py +166 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/pyproject.toml +1 -1
- nookplot_runtime-0.5.137/tests/test_api_sub_categories.py +40 -0
- nookplot_runtime-0.5.137/tests/test_goal_loop.py +394 -0
- nookplot_runtime-0.5.137/tests/test_profiles.py +227 -0
- nookplot_runtime-0.5.137/tests/test_usdc_budget.py +98 -0
- nookplot_runtime-0.5.137/tests/test_x402.py +124 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/.gitignore +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/README.md +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/SKILL.md +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/action_catalog.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/artifact_embeddings.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/autonomous.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/cognitive_workspace.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/content_safety.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/conversation/__init__.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/conversation/compaction_memory.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/conversation/conversation_log_store.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/conversation/conversation_memory.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/conversation/model_limits.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/cro.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/default_guardrails.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/doom_loop.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/embedding_exchange.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/evaluator.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/events.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/formatters.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/guardrails.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/hooks.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/knowledge_context.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/manifest.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/manifest_activation_hook.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/mining.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/query_segmentation.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/sandbox.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/signal_action_map.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/surplus_inference.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/types.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/wake_up_stack.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/requirements.lock +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/__init__.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/conversation/__init__.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/conversation/test_compaction_memory.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/helpers/__init__.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/helpers/mock_runtime.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_autonomous_action_dispatch.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_autonomous_dedup.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_autonomous_doom_loop.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_autonomous_guardrails.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_autonomous_hooks.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_autonomous_lifecycle.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_autonomous_loaded_skill_refs.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_bounty_create.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_client.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_content_safety.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_doom_loop.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_economy_frontier_inference.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_economy_surplus_branch.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_external_mcp_tools.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_get_available_actions.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_guardrails.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_hooks.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_latent_space.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_manifest_activation_hook.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_pack_gating.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_query_segmentation.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_sandbox.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_surplus_inference.py +0 -0
- {nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/tests/test_wake_up_stack.py +0 -0
- {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.
|
|
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.
|
|
324
|
+
__version__ = "0.5.137"
|
{nookplot_runtime-0.5.135 → nookplot_runtime-0.5.137}/nookplot_runtime/action_catalog_generated.py
RENAMED
|
@@ -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
|
|
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
|
+
)
|