hyperagents 1.2.3rc5.post3__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.
- agents/__init__.py +31 -0
- agents/background_monitor.py +453 -0
- agents/learning.py +460 -0
- agents/planner.py +421 -0
- agents/transcriber.py +341 -0
- civilization/__init__.py +51 -0
- civilization/api/__init__.py +7 -0
- civilization/api/civilization_api.py +361 -0
- civilization/graph/__init__.py +19 -0
- civilization/graph/knowledge_linker.py +348 -0
- civilization/graph/org_graph.py +348 -0
- civilization/graph/process_graph.py +386 -0
- civilization/ingestion/__init__.py +20 -0
- civilization/ingestion/chunker.py +273 -0
- civilization/ingestion/document_ingestor.py +358 -0
- civilization/ingestion/embedder.py +262 -0
- civilization/ingestion/extractor.py +224 -0
- civilization/interview/__init__.py +19 -0
- civilization/interview/gap_detector.py +380 -0
- civilization/interview/interviewer.py +323 -0
- civilization/interview/questioner.py +363 -0
- civilization/retrieval/__init__.py +17 -0
- civilization/retrieval/civilization_rag.py +316 -0
- civilization/retrieval/context_injector.py +342 -0
- civilization/retrieval/relevance_ranker.py +287 -0
- civilization/schema.py +299 -0
- civilization/store.py +177 -0
- civilization/sync/__init__.py +16 -0
- civilization/sync/confluence_sync.py +317 -0
- civilization/sync/gdrive_sync.py +262 -0
- civilization/sync/notion_sync.py +237 -0
- civilization/versioning/__init__.py +19 -0
- civilization/versioning/diff_engine.py +351 -0
- civilization/versioning/staleness_detector.py +303 -0
- civilization/versioning/version_manager.py +293 -0
- cli/__init__.py +1 -0
- cli/doctor.py +334 -0
- cli/hyperclaw.py +1357 -0
- cli/onboarding.py +469 -0
- core/__init__.py +1 -0
- core/connectors/__init__.py +119 -0
- core/connectors/infrastructure.py +772 -0
- core/connectors/messaging.py +586 -0
- core/connectors/productivity.py +764 -0
- core/connectors/smarthome.py +528 -0
- core/connectors/social.py +730 -0
- core/hyperrouter/__init__.py +23 -0
- core/hyperrouter/bandit.py +218 -0
- core/hyperrouter/fast_loop.py +68 -0
- core/hyperrouter/slow_loop.py +143 -0
- core/hyperstate/__init__.py +24 -0
- core/hyperstate/certifier.py +154 -0
- core/hyperstate/schema.py +160 -0
- core/hyperstate/state_manager.py +103 -0
- core/hyperstate/store.py +163 -0
- core/providers/__init__.py +121 -0
- core/providers/embeddings.py +389 -0
- core/providers/image.py +534 -0
- core/providers/llm.py +1935 -0
- core/providers/search.py +608 -0
- core/providers/voice.py +474 -0
- hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
- hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
- hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
- hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
- hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
- hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
- hyperclaw/__init__.py +67 -0
- hyperclaw/__main__.py +220 -0
- hyperclaw/agent.py +775 -0
- hyperclaw/agent_coordinator.py +577 -0
- hyperclaw/anthropic_params.py +225 -0
- hyperclaw/api_utils.py +85 -0
- hyperclaw/approval_telegram.py +209 -0
- hyperclaw/approval_terminal.py +80 -0
- hyperclaw/attachments.py +177 -0
- hyperclaw/branding.py +55 -0
- hyperclaw/brief/__init__.py +11 -0
- hyperclaw/brief/cli.py +204 -0
- hyperclaw/brief/collect.py +146 -0
- hyperclaw/brief/compose.py +333 -0
- hyperclaw/brief/diff.py +110 -0
- hyperclaw/brief/errors.py +99 -0
- hyperclaw/brief/google.py +269 -0
- hyperclaw/brief/google_auth.py +294 -0
- hyperclaw/brief/links.py +124 -0
- hyperclaw/brief/service.py +424 -0
- hyperclaw/brief/store.py +205 -0
- hyperclaw/brief/tasks.py +200 -0
- hyperclaw/chat_scope.py +115 -0
- hyperclaw/cli_tui.py +497 -0
- hyperclaw/client_guard.py +717 -0
- hyperclaw/connection_check.py +113 -0
- hyperclaw/cost_guard.py +1728 -0
- hyperclaw/default_config/agents.yaml +534 -0
- hyperclaw/default_config/hyperclaw.yaml +27 -0
- hyperclaw/default_config/models.yaml +161 -0
- hyperclaw/deployment.py +39 -0
- hyperclaw/digest/__init__.py +9 -0
- hyperclaw/digest/cli.py +117 -0
- hyperclaw/digest/errors.py +100 -0
- hyperclaw/digest/gitlab.py +187 -0
- hyperclaw/digest/http.py +113 -0
- hyperclaw/digest/redact.py +32 -0
- hyperclaw/digest/service.py +271 -0
- hyperclaw/digest/slack.py +223 -0
- hyperclaw/digest/state.py +124 -0
- hyperclaw/digest/summarize.py +198 -0
- hyperclaw/digest/tokens.py +143 -0
- hyperclaw/distribution.py +46 -0
- hyperclaw/documents.py +351 -0
- hyperclaw/feeds.py +339 -0
- hyperclaw/google_workspace/__init__.py +5 -0
- hyperclaw/google_workspace/api.py +151 -0
- hyperclaw/google_workspace/auth.py +397 -0
- hyperclaw/google_workspace/calendar.py +157 -0
- hyperclaw/google_workspace/cli.py +72 -0
- hyperclaw/google_workspace/drive.py +236 -0
- hyperclaw/google_workspace/errors.py +58 -0
- hyperclaw/google_workspace/gmail.py +295 -0
- hyperclaw/google_workspace/meet.py +122 -0
- hyperclaw/google_workspace/tools.py +319 -0
- hyperclaw/hyperspeed.py +57 -0
- hyperclaw/integrations_layer.py +1427 -0
- hyperclaw/live_state.py +188 -0
- hyperclaw/managed_config.py +245 -0
- hyperclaw/media_hub.py +179 -0
- hyperclaw/memory_manager.py +584 -0
- hyperclaw/model_catalog.py +551 -0
- hyperclaw/model_policy.py +211 -0
- hyperclaw/model_router.py +595 -0
- hyperclaw/model_selector.py +72 -0
- hyperclaw/naming.py +284 -0
- hyperclaw/notices.py +65 -0
- hyperclaw/oai_stream.py +106 -0
- hyperclaw/orchestrator.py +591 -0
- hyperclaw/outbox.py +82 -0
- hyperclaw/persona.py +367 -0
- hyperclaw/prometheus.py +219 -0
- hyperclaw/prompt_assembly.py +157 -0
- hyperclaw/provider_setup.py +146 -0
- hyperclaw/providers.py +340 -0
- hyperclaw/qualification.py +341 -0
- hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
- hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
- hyperclaw/reasoning_display.py +35 -0
- hyperclaw/scheduler.py +454 -0
- hyperclaw/server.py +847 -0
- hyperclaw/setup.py +627 -0
- hyperclaw/skills.py +717 -0
- hyperclaw/solomon.py +442 -0
- hyperclaw/swarm.py +533 -0
- hyperclaw/taint.py +832 -0
- hyperclaw/team/__init__.py +28 -0
- hyperclaw/team/dispatcher.py +431 -0
- hyperclaw/team/genesis.py +106 -0
- hyperclaw/team/lead.py +110 -0
- hyperclaw/team/model.py +135 -0
- hyperclaw/team/morning.py +238 -0
- hyperclaw/team/repos.py +66 -0
- hyperclaw/team/roster.py +264 -0
- hyperclaw/team/specialist.py +235 -0
- hyperclaw/team/status.py +163 -0
- hyperclaw/team/watchers.py +112 -0
- hyperclaw/teamsetup/__init__.py +17 -0
- hyperclaw/teamsetup/checks.py +208 -0
- hyperclaw/teamsetup/item.py +391 -0
- hyperclaw/teamsetup/oauth_loopback.py +84 -0
- hyperclaw/teamsetup/onboard.py +550 -0
- hyperclaw/teamsetup/profile.py +82 -0
- hyperclaw/teamsetup/secrets.py +313 -0
- hyperclaw/teamsetup/selfcheck.py +295 -0
- hyperclaw/teamsetup/service.py +495 -0
- hyperclaw/teamsetup/slack_oauth.py +442 -0
- hyperclaw/teamsetup/tty.py +62 -0
- hyperclaw/telegram_bot.py +1106 -0
- hyperclaw/telegram_intake.py +126 -0
- hyperclaw/telegram_pairing.py +360 -0
- hyperclaw/terminal_provider.py +323 -0
- hyperclaw/tools/__init__.py +1 -0
- hyperclaw/tools/approval.py +949 -0
- hyperclaw/tools/args.py +212 -0
- hyperclaw/tools/catalog.py +244 -0
- hyperclaw/tools/classify.py +266 -0
- hyperclaw/tools/effects.py +724 -0
- hyperclaw/tools/egress.py +210 -0
- hyperclaw/tools/envelope.py +188 -0
- hyperclaw/tools/guard.py +367 -0
- hyperclaw/tools/ledger.py +339 -0
- hyperclaw/tools/receipts.py +70 -0
- hyperclaw/tools/registry.py +318 -0
- hyperclaw/tools/service_guard.py +151 -0
- hyperclaw/tools/surfaces.py +54 -0
- hyperclaw/tools/workspace.py +87 -0
- hyperclaw/trading/__init__.py +77 -0
- hyperclaw/truth_guard.py +791 -0
- hyperclaw/tts.py +124 -0
- hyperclaw/tui.py +4255 -0
- hyperclaw/tui_bridge.py +938 -0
- hyperclaw/unattended.py +60 -0
- hyperclaw/updates/__init__.py +54 -0
- hyperclaw/updates/check.py +131 -0
- hyperclaw/updates/index.py +178 -0
- hyperclaw/updates/install.py +110 -0
- hyperclaw/updates/managed.py +161 -0
- hyperclaw/updates/manifest.py +138 -0
- hyperclaw/updates/notices.py +86 -0
- hyperclaw/updates/paths.py +53 -0
- hyperclaw/updates/release.py +146 -0
- hyperclaw/updates/upgrade.py +253 -0
- hyperclaw/updates/versioning.py +104 -0
- hyperclaw/userconfig.py +119 -0
- hyperclaw/xai.py +83 -0
- integrations/__init__.py +31 -0
- integrations/automation/__init__.py +1 -0
- integrations/automation/make.py +87 -0
- integrations/automation/zapier.py +87 -0
- integrations/base.py +476 -0
- integrations/communication/__init__.py +1 -0
- integrations/communication/twilio.py +279 -0
- integrations/communication/zoom.py +290 -0
- integrations/data/__init__.py +1 -0
- integrations/data/google_analytics.py +135 -0
- integrations/data/postgres.py +182 -0
- integrations/data/supabase_connector.py +202 -0
- integrations/data/weather_feeds.py +450 -0
- integrations/developer/__init__.py +9 -0
- integrations/developer/github.py +406 -0
- integrations/developer/gitlab.py +298 -0
- integrations/enterprise/__init__.py +13 -0
- integrations/enterprise/confluence.py +266 -0
- integrations/enterprise/hubspot.py +293 -0
- integrations/enterprise/jira.py +314 -0
- integrations/enterprise/salesforce.py +286 -0
- integrations/finance/__init__.py +1 -0
- integrations/finance/polymarket_client.py +247 -0
- integrations/finance/quickbooks.py +281 -0
- integrations/finance/stripe.py +261 -0
- integrations/gateway.py +238 -0
- integrations/messaging/__init__.py +32 -0
- integrations/messaging/discord.py +159 -0
- integrations/messaging/email.py +401 -0
- integrations/messaging/imessage.py +443 -0
- integrations/messaging/signal.py +175 -0
- integrations/messaging/slack.py +217 -0
- integrations/messaging/sms.py +175 -0
- integrations/messaging/teams.py +208 -0
- integrations/messaging/telegram.py +260 -0
- integrations/messaging/whatsapp.py +203 -0
- integrations/productivity/__init__.py +17 -0
- integrations/productivity/airtable.py +217 -0
- integrations/productivity/asana.py +248 -0
- integrations/productivity/google/__init__.py +19 -0
- integrations/productivity/google/base.py +89 -0
- integrations/productivity/google/calendar.py +272 -0
- integrations/productivity/google/docs.py +202 -0
- integrations/productivity/google/drive.py +268 -0
- integrations/productivity/google/gmail.py +279 -0
- integrations/productivity/google/meet.py +218 -0
- integrations/productivity/google/sheets.py +222 -0
- integrations/productivity/google/tasks.py +199 -0
- integrations/productivity/linear.py +279 -0
- integrations/productivity/microsoft/__init__.py +13 -0
- integrations/productivity/microsoft/base.py +93 -0
- integrations/productivity/microsoft/calendar.py +207 -0
- integrations/productivity/microsoft/onedrive.py +222 -0
- integrations/productivity/microsoft/outlook.py +241 -0
- integrations/productivity/microsoft/sharepoint.py +211 -0
- integrations/productivity/notion.py +243 -0
- integrations/productivity/todoist.py +250 -0
- integrations/productivity/trello.py +244 -0
- integrations/storage/__init__.py +1 -0
- integrations/storage/box.py +279 -0
- integrations/storage/dropbox.py +215 -0
- integrations/telegram_inbound.py +179 -0
- integrations/telegram_poller.py +151 -0
- memory/__init__.py +1 -0
- memory/causal_graph.py +125 -0
- memory/impact_tracker.py +88 -0
- memory/vector_memory.py +436 -0
- models/__init__.py +12 -0
- models/chatjimmy_client.py +210 -0
- models/claude_client.py +235 -0
- models/claude_code_subagent.py +192 -0
- models/openai_client.py +120 -0
- models/router.py +162 -0
- recursive/discovery_loop.py +118 -0
- recursive/optimization_loop.py +135 -0
- recursive/validation_loop.py +38 -0
- security/__init__.py +1 -0
- security/audit_logger.py +106 -0
- security/filesystem_guard.py +100 -0
- security/hypershield.py +99 -0
- security/network_guard.py +97 -0
- security/policy_engine.py +198 -0
- swarm/__init__.py +1 -0
- swarm/agents/__init__.py +0 -0
- swarm/agents/base.py +230 -0
- swarm/agents/business/__init__.py +0 -0
- swarm/agents/business/counsel.py +31 -0
- swarm/agents/business/herald.py +32 -0
- swarm/agents/business/ledger.py +26 -0
- swarm/agents/business/nexus.py +30 -0
- swarm/agents/business/ops.py +30 -0
- swarm/agents/business/pipeline.py +28 -0
- swarm/agents/business/revenue.py +30 -0
- swarm/agents/business/sovereign.py +31 -0
- swarm/agents/business/strategos.py +27 -0
- swarm/agents/business/talent.py +27 -0
- swarm/agents/business/venture.py +32 -0
- swarm/agents/comms/__init__.py +1 -0
- swarm/agents/comms/cipher.py +32 -0
- swarm/agents/comms/echo_comms.py +31 -0
- swarm/agents/comms/envoy.py +31 -0
- swarm/agents/comms/herald_scheduler.py +33 -0
- swarm/agents/comms/pulse.py +29 -0
- swarm/agents/creative/__init__.py +0 -0
- swarm/agents/creative/author.py +27 -0
- swarm/agents/creative/lens.py +25 -0
- swarm/agents/executive/__init__.py +1 -0
- swarm/agents/intelligence/__init__.py +1 -0
- swarm/agents/intelligence/arbiter.py +30 -0
- swarm/agents/intelligence/sentinel.py +30 -0
- swarm/agents/media/__init__.py +0 -0
- swarm/agents/personal/__init__.py +0 -0
- swarm/agents/personal/atlas.py +27 -0
- swarm/agents/personal/hearth.py +26 -0
- swarm/agents/personal/midas.py +27 -0
- swarm/agents/personal/navigator.py +27 -0
- swarm/agents/personal/nourish.py +31 -0
- swarm/agents/personal/vitals.py +33 -0
- swarm/agents/recursive/__init__.py +0 -0
- swarm/agents/recursive/alchemist.py +73 -0
- swarm/agents/recursive/calibrator.py +45 -0
- swarm/agents/recursive/scout.py +97 -0
- swarm/agents/scientific/__init__.py +0 -0
- swarm/agents/scientific/cosmos.py +36 -0
- swarm/agents/scientific/gaia.py +26 -0
- swarm/agents/scientific/medicus.py +33 -0
- swarm/agents/scientific/oracle_agent.py +39 -0
- swarm/agents/scientific/scribe.py +39 -0
- swarm/agents/talent/__init__.py +0 -0
- swarm/agents/talent/deal.py +28 -0
- swarm/agents/talent/roster.py +29 -0
- swarm/agents/talent/scout.py +29 -0
- swarm/agents/talent/stage.py +27 -0
- swarm/agents/tech/__init__.py +0 -0
- swarm/agents/tech/aegis.py +28 -0
- swarm/agents/tech/bridge.py +28 -0
- swarm/agents/tech/forge.py +28 -0
- swarm/agents/trading/__init__.py +1 -0
- swarm/agents/trading/global_prediction_engine.py +611 -0
- swarm/agents/trading/polymarket_trader.py +165 -0
- swarm/agents/trading/prediction_strategist.py +218 -0
- swarm/autogen_bridge.py +68 -0
- swarm/bid_protocol.py +196 -0
- swarm/dispatcher.py +527 -0
- swarm/nexus.py +181 -0
- swarm/registry.py +112 -0
- telegram_listener.py +403 -0
- ui/tui/app.py +214 -0
hyperclaw/tools/guard.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The per-turn runtime guard (tool standard wave 2: S3 idempotency, S5 retry
|
|
3
|
+
budgets). One TurnGuard is created per user message by every loop that runs
|
|
4
|
+
tools (the terminal's Anthropic and OpenAI-compatible loops and the chat
|
|
5
|
+
bridge) and passed to ToolRegistry.dispatch(..., guard=). The registry calls:
|
|
6
|
+
|
|
7
|
+
precheck(spec) before argument validation: turn cap, per-tool budget
|
|
8
|
+
on_invalid(spec) after invalid_args: one corrected retry is free
|
|
9
|
+
check_budgets(spec, args) before the approval gate (wave 3): per-resource and
|
|
10
|
+
per-target budgets, so a refused call is never queued
|
|
11
|
+
before(spec, args) per-resource budget, per-target retry budget,
|
|
12
|
+
effect-ledger check + intent row
|
|
13
|
+
after(spec, args, env, ctx) outcome row, counters, not_found resources
|
|
14
|
+
observe(spec, args, env) an owner-approved call's outcome, counted in this turn
|
|
15
|
+
|
|
16
|
+
The model never sees a key and cannot opt a call out. What persists across
|
|
17
|
+
turns and restarts is the ledger (hyperclaw/tools/ledger.py); the budgets and
|
|
18
|
+
the not-found set are per turn, as the standard says.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
from . import effects as fx
|
|
29
|
+
from .envelope import (REASON_CONFIRM, REASON_RESOURCE_MISSING, REASON_RETRY_BUDGET,
|
|
30
|
+
REASON_TURN_CAP, REASON_UNKNOWN_TOOL, Status, ToolResult, make,
|
|
31
|
+
new_receipt_id)
|
|
32
|
+
from .ledger import CONFLICT_EFFECTS, EffectLedger, get_ledger
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger("hyperclaw.tools.guard")
|
|
35
|
+
|
|
36
|
+
# Statuses that are not a failed attempt (they don't spend retry budget).
|
|
37
|
+
_NOT_FAILURES = {Status.OK.value, Status.DUPLICATE.value, Status.NEEDS_APPROVAL.value,
|
|
38
|
+
Status.BUDGET_EXHAUSTED.value, Status.INVALID_ARGS.value}
|
|
39
|
+
|
|
40
|
+
CHAT_TOOL_CALL_CAP = 8 # chat surfaces (bridge/Telegram); the terminal keeps 25 rounds
|
|
41
|
+
|
|
42
|
+
TOOL_CAP_NOTE = (
|
|
43
|
+
"[HyperClaw system note: the tool-call limit for one message ({limit}) was reached, so "
|
|
44
|
+
"this turn was stopped. These calls did NOT run: {names}.]"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_VERB = {"send": "send", "spend": "spend", "delete": "delete", "write": "write",
|
|
48
|
+
"system": "action"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def chat_tool_call_cap() -> int:
|
|
52
|
+
try:
|
|
53
|
+
return int(os.environ.get("HYPERCLAW_CHAT_TOOL_CALL_CAP", CHAT_TOOL_CALL_CAP))
|
|
54
|
+
except ValueError:
|
|
55
|
+
return CHAT_TOOL_CALL_CAP
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _when(ts) -> str:
|
|
59
|
+
try:
|
|
60
|
+
return time.strftime("%Y-%m-%d %H:%M", time.localtime(float(ts)))
|
|
61
|
+
except (TypeError, ValueError):
|
|
62
|
+
return "earlier"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _display_target(target: str, tool: str) -> str:
|
|
66
|
+
if target.startswith("file:"):
|
|
67
|
+
return target[5:]
|
|
68
|
+
if target.startswith(tool + ":"):
|
|
69
|
+
return target[len(tool) + 1:]
|
|
70
|
+
return target
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TurnGuard:
|
|
74
|
+
def __init__(self, *, conversation: str, surface: str,
|
|
75
|
+
ledger: Optional[EffectLedger] = None, call_cap: Optional[int] = None,
|
|
76
|
+
turn_id: Optional[str] = None, base_dir: Optional[str] = None,
|
|
77
|
+
provenance: Optional[List[str]] = None, requester: str = "owner",
|
|
78
|
+
approved: bool = False):
|
|
79
|
+
self.conversation = conversation
|
|
80
|
+
# Wave 3 (approval layer). provenance: the user's own words and the
|
|
81
|
+
# content they gave this conversation (a read-only fetch may only
|
|
82
|
+
# target a URL that appears verbatim here). requester: "owner" or
|
|
83
|
+
# "agent:<id>" for sub-agents. approved: set only for the run of an
|
|
84
|
+
# owner-approved payload (tools.approval.send).
|
|
85
|
+
self.provenance: List[str] = [p for p in (provenance or []) if isinstance(p, str)]
|
|
86
|
+
self.requester = requester
|
|
87
|
+
self.approved = approved
|
|
88
|
+
self.untrusted_seen = False # a read of email/web/document content ran this turn
|
|
89
|
+
self.memory_saved_unprompted: List[str] = [] # own-words memories saved this turn
|
|
90
|
+
self.queued: List[str] = [] # approval ids queued this turn
|
|
91
|
+
self.surface = surface
|
|
92
|
+
self._ledger = ledger
|
|
93
|
+
self.call_cap = call_cap
|
|
94
|
+
self.turn_id = turn_id or uuid.uuid4().hex[:12]
|
|
95
|
+
self.base_dir = base_dir
|
|
96
|
+
self.calls = 0
|
|
97
|
+
self.failures: Dict[Tuple[str, str], int] = {}
|
|
98
|
+
self.invalid: Dict[str, int] = {}
|
|
99
|
+
self.missing: List[fx.Resource] = []
|
|
100
|
+
self.skipped: List[str] = []
|
|
101
|
+
self.cap_hit = False
|
|
102
|
+
# Every tool that ran this turn, with the status it really returned:
|
|
103
|
+
# the evidence the cost guard's stop message is built from.
|
|
104
|
+
self.results: List[Dict[str, Any]] = []
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def ledger(self) -> EffectLedger:
|
|
108
|
+
if self._ledger is None:
|
|
109
|
+
self._ledger = get_ledger()
|
|
110
|
+
return self._ledger
|
|
111
|
+
|
|
112
|
+
# ── before validation ──
|
|
113
|
+
def precheck(self, spec) -> Optional[ToolResult]:
|
|
114
|
+
if self.call_cap is not None and self.calls >= self.call_cap:
|
|
115
|
+
self.cap_hit = True
|
|
116
|
+
self.skipped.append(spec.name)
|
|
117
|
+
return make(Status.BUDGET_EXHAUSTED,
|
|
118
|
+
f"{spec.name} was NOT run: the limit of {self.call_cap} tool calls for "
|
|
119
|
+
f"one message was reached.", reason=REASON_TURN_CAP)
|
|
120
|
+
self.calls += 1
|
|
121
|
+
if self.failures.get((spec.name, "*"), 0) > spec.retry_budget:
|
|
122
|
+
return self._retry_refusal(spec, "*")
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
def on_invalid(self, spec) -> None:
|
|
126
|
+
n = self.invalid.get(spec.name, 0) + 1
|
|
127
|
+
self.invalid[spec.name] = n
|
|
128
|
+
if n >= 2: # one corrected retry is free; the next invalid_args counts
|
|
129
|
+
self.failures[(spec.name, "*")] = self.failures.get((spec.name, "*"), 0) + 1
|
|
130
|
+
|
|
131
|
+
def _retry_refusal(self, spec, tkey: str) -> ToolResult:
|
|
132
|
+
where = "" if tkey == "*" else f" on {tkey.split(':', 1)[-1]}"
|
|
133
|
+
return make(Status.BUDGET_EXHAUSTED,
|
|
134
|
+
f"{spec.name} was NOT run: it already failed{where} this turn and its retry "
|
|
135
|
+
f"budget ({spec.retry_budget}) is used up.", reason=REASON_RETRY_BUDGET)
|
|
136
|
+
|
|
137
|
+
# ── after validation, before the approval gate ──
|
|
138
|
+
def check_budgets(self, spec, args: dict) -> Optional[ToolResult]:
|
|
139
|
+
"""The S5 refusals (still looking for something not found this turn;
|
|
140
|
+
retry budget used up), checked BEFORE a call is queued for approval,
|
|
141
|
+
so a call this turn would refuse is never put in front of the owner.
|
|
142
|
+
Writes nothing but the refusal row. before() repeats the same checks."""
|
|
143
|
+
res = fx.resources(spec.name, args, self.base_dir)
|
|
144
|
+
check = [r for r in res if r.kind != "path"] if spec.name in fx.CREATE_TOOLS else res
|
|
145
|
+
eff = None
|
|
146
|
+
if check:
|
|
147
|
+
for m in self.missing:
|
|
148
|
+
if any(fx.touches(r, m) for r in check):
|
|
149
|
+
eff = fx.call_effect(spec, args)
|
|
150
|
+
env = make(Status.BUDGET_EXHAUSTED,
|
|
151
|
+
f"{spec.name} was NOT run: it is still looking for {m.value}, "
|
|
152
|
+
f"which was not found earlier in this turn.",
|
|
153
|
+
reason=REASON_RESOURCE_MISSING)
|
|
154
|
+
self._log_refusal(spec, eff, args, env)
|
|
155
|
+
return env
|
|
156
|
+
eff = fx.call_effect(spec, args)
|
|
157
|
+
tkey = "*"
|
|
158
|
+
if eff is not None and eff.target != spec.name:
|
|
159
|
+
tkey = eff.target
|
|
160
|
+
elif res and res[0].kind != "text":
|
|
161
|
+
tkey = str(res[0])
|
|
162
|
+
if self.failures.get((spec.name, tkey), 0) > spec.retry_budget:
|
|
163
|
+
env = self._retry_refusal(spec, tkey)
|
|
164
|
+
self._log_refusal(spec, eff, args, env)
|
|
165
|
+
return env
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def observe(self, spec, args: dict, env: ToolResult) -> None:
|
|
169
|
+
"""An owner-approved call ran (tools.approval.send, under its own
|
|
170
|
+
guard, which wrote the ledger rows). Count its outcome in THIS turn's
|
|
171
|
+
budgets too, so a failed approved write is not retried this turn and
|
|
172
|
+
a not-found resource stays not found."""
|
|
173
|
+
tkey = "*"
|
|
174
|
+
try:
|
|
175
|
+
eff = fx.call_effect(spec, args)
|
|
176
|
+
res = fx.resources(spec.name, args, self.base_dir)
|
|
177
|
+
if eff is not None and eff.target != spec.name:
|
|
178
|
+
tkey = eff.target
|
|
179
|
+
elif res and res[0].kind != "text":
|
|
180
|
+
tkey = str(res[0])
|
|
181
|
+
except Exception:
|
|
182
|
+
res = []
|
|
183
|
+
self._count(spec, env, {"resources": res, "tkey": tkey})
|
|
184
|
+
|
|
185
|
+
# ── after validation, before running ──
|
|
186
|
+
def before(self, spec, args: dict) -> Tuple[Optional[ToolResult], Dict[str, Any]]:
|
|
187
|
+
res = fx.resources(spec.name, args, self.base_dir)
|
|
188
|
+
eff = fx.call_effect(spec, args)
|
|
189
|
+
ctx: Dict[str, Any] = {"resources": res, "effect": eff, "intent_id": None,
|
|
190
|
+
"writes": [], "tkey": "*"}
|
|
191
|
+
if eff is not None and eff.target != spec.name:
|
|
192
|
+
ctx["tkey"] = eff.target
|
|
193
|
+
elif res and res[0].kind != "text":
|
|
194
|
+
ctx["tkey"] = str(res[0])
|
|
195
|
+
|
|
196
|
+
# S5 per resource: still going after something that was not found.
|
|
197
|
+
# A tool that creates a file may still create it at that path (its
|
|
198
|
+
# inputs, e.g. a download URL, are still checked).
|
|
199
|
+
check = [r for r in res if r.kind != "path"] if spec.name in fx.CREATE_TOOLS else res
|
|
200
|
+
if check:
|
|
201
|
+
for m in self.missing:
|
|
202
|
+
if any(fx.touches(r, m) for r in check):
|
|
203
|
+
env = make(Status.BUDGET_EXHAUSTED,
|
|
204
|
+
f"{spec.name} was NOT run: it is still looking for {m.value}, "
|
|
205
|
+
f"which was not found earlier in this turn.",
|
|
206
|
+
reason=REASON_RESOURCE_MISSING)
|
|
207
|
+
self._log_refusal(spec, eff, args, env)
|
|
208
|
+
return env, ctx
|
|
209
|
+
|
|
210
|
+
# S5 per tool (and target).
|
|
211
|
+
if self.failures.get((spec.name, ctx["tkey"]), 0) > spec.retry_budget:
|
|
212
|
+
env = self._retry_refusal(spec, ctx["tkey"])
|
|
213
|
+
self._log_refusal(spec, eff, args, env)
|
|
214
|
+
return env, ctx
|
|
215
|
+
|
|
216
|
+
# S3 effect ledger.
|
|
217
|
+
if spec.name == "bash":
|
|
218
|
+
return self._before_bash(spec, args, ctx)
|
|
219
|
+
if eff is None:
|
|
220
|
+
return None, ctx
|
|
221
|
+
try:
|
|
222
|
+
d = self.ledger.begin(
|
|
223
|
+
conversation=self.conversation, turn=self.turn_id, surface=self.surface,
|
|
224
|
+
tool=spec.name, effect=eff.effect, target=eff.target,
|
|
225
|
+
payload_hash=eff.payload_hash, key=eff.key, args=fx.summarize_args(args),
|
|
226
|
+
current_file_hash=fx.file_hash(eff.file_path),
|
|
227
|
+
expected_post_hash=eff.expected_post_hash, is_file=bool(eff.file_path),
|
|
228
|
+
allow_conflict=self.approved)
|
|
229
|
+
except Exception as e:
|
|
230
|
+
log.warning("effect ledger unavailable: %s", e, exc_info=True)
|
|
231
|
+
if eff.effect in CONFLICT_EFFECTS:
|
|
232
|
+
return make(Status.FAILED, f"{spec.name} was NOT run: the activity ledger is "
|
|
233
|
+
f"unavailable ({type(e).__name__}), so the runtime "
|
|
234
|
+
f"cannot guarantee it runs at most once."), ctx
|
|
235
|
+
return None, ctx
|
|
236
|
+
if d.action == "proceed":
|
|
237
|
+
ctx["intent_id"] = d.intent_id
|
|
238
|
+
return None, ctx
|
|
239
|
+
return self._held(spec, eff, d), ctx
|
|
240
|
+
|
|
241
|
+
def _held(self, spec, eff: fx.Effect, d) -> ToolResult:
|
|
242
|
+
prior = d.prior or {}
|
|
243
|
+
when = _when(prior.get("ts"))
|
|
244
|
+
receipt = prior.get("receipt_id")
|
|
245
|
+
target = _display_target(eff.target, spec.name)
|
|
246
|
+
original = {"tool": prior.get("tool"), "at": when, "receipt_id": receipt,
|
|
247
|
+
"target": prior.get("target"), "status": prior.get("status")}
|
|
248
|
+
verb = _VERB.get(eff.effect, "action")
|
|
249
|
+
if d.action == "duplicate":
|
|
250
|
+
return make(Status.DUPLICATE,
|
|
251
|
+
f"{spec.name} was NOT run: the same {verb} to {target} was already done "
|
|
252
|
+
f"at {when} by {prior.get('tool')}"
|
|
253
|
+
+ (f" (receipt {receipt})." if receipt else "."),
|
|
254
|
+
data={"original": original}, receipt_id=receipt)
|
|
255
|
+
if d.reason == "uncertain":
|
|
256
|
+
msg = (f"{spec.name} was NOT run: an identical {verb} to {target} at {when} has an "
|
|
257
|
+
f"uncertain outcome; it may already have been sent. Ask the user to check "
|
|
258
|
+
f"before trying again.")
|
|
259
|
+
else:
|
|
260
|
+
msg = (f"{spec.name} was NOT run: a different {verb} to {target} was already done at "
|
|
261
|
+
f"{when}" + (f" (receipt {receipt})" if receipt else "") +
|
|
262
|
+
f". Ask the user whether to {verb} this as well.")
|
|
263
|
+
return make(Status.NEEDS_APPROVAL, msg, data={"original": original},
|
|
264
|
+
reason=REASON_CONFIRM)
|
|
265
|
+
|
|
266
|
+
def _before_bash(self, spec, args, ctx) -> Tuple[Optional[ToolResult], Dict[str, Any]]:
|
|
267
|
+
writes = fx.bash_file_writes(args.get("command", ""), self.base_dir)
|
|
268
|
+
ctx["writes"] = writes
|
|
269
|
+
ctx["pre"] = {w.path: fx.file_hash(w.path) for w in writes}
|
|
270
|
+
try:
|
|
271
|
+
for path, eff in fx.planned_file_effects(writes):
|
|
272
|
+
if eff is None:
|
|
273
|
+
continue
|
|
274
|
+
d = self.ledger.check(conversation=self.conversation, effect="write",
|
|
275
|
+
target=eff.target, key=eff.key,
|
|
276
|
+
current_file_hash=fx.file_hash(path),
|
|
277
|
+
expected_post_hash=eff.expected_post_hash, is_file=True)
|
|
278
|
+
if d.action == "duplicate":
|
|
279
|
+
env = self._held(spec, eff, d)
|
|
280
|
+
self._log_refusal(spec, eff, args, env)
|
|
281
|
+
return env, ctx
|
|
282
|
+
d = self.ledger.begin(conversation=self.conversation, turn=self.turn_id,
|
|
283
|
+
surface=self.surface, tool="bash", effect=spec.effect,
|
|
284
|
+
target="bash", payload_hash=fx.sha256(args.get("command", "")),
|
|
285
|
+
key=None, args=fx.summarize_args(args))
|
|
286
|
+
ctx["intent_id"] = d.intent_id
|
|
287
|
+
except Exception as e:
|
|
288
|
+
log.warning("effect ledger unavailable for bash: %s", e, exc_info=True)
|
|
289
|
+
return None, ctx
|
|
290
|
+
|
|
291
|
+
# ── after running ──
|
|
292
|
+
def after(self, spec, args: dict, env: ToolResult, ctx: Dict[str, Any]) -> ToolResult:
|
|
293
|
+
self._count(spec, env, ctx)
|
|
294
|
+
try:
|
|
295
|
+
self._record_outcome(spec, env, ctx)
|
|
296
|
+
except Exception as e:
|
|
297
|
+
log.warning("effect ledger: could not record outcome: %s", e, exc_info=True)
|
|
298
|
+
return env
|
|
299
|
+
|
|
300
|
+
def _count(self, spec, env: ToolResult, ctx: Dict[str, Any]) -> None:
|
|
301
|
+
status = env.status
|
|
302
|
+
eff = ctx.get("effect") if ctx else None
|
|
303
|
+
self.results.append({"tool": spec.name, "status": status,
|
|
304
|
+
"target": _display_target(eff.target, spec.name)[:80]
|
|
305
|
+
if eff is not None and eff.target else "",
|
|
306
|
+
"receipt_id": env.receipt_id})
|
|
307
|
+
if spec.effect == "read" and status == Status.OK.value:
|
|
308
|
+
from .approval import PURE_COMPUTE
|
|
309
|
+
if spec.name not in PURE_COMPUTE:
|
|
310
|
+
self.untrusted_seen = True
|
|
311
|
+
# Connector content (email, calendar, documents, web): recorded in
|
|
312
|
+
# this conversation's taint ledger (hyperclaw.taint).
|
|
313
|
+
try:
|
|
314
|
+
from hyperclaw import taint
|
|
315
|
+
taint.mark_tool_result(spec.name, env, self.conversation, self.turn_id)
|
|
316
|
+
except Exception:
|
|
317
|
+
log.warning("taint ledger: could not record %s", spec.name, exc_info=True)
|
|
318
|
+
if status not in _NOT_FAILURES:
|
|
319
|
+
k = (spec.name, ctx.get("tkey", "*"))
|
|
320
|
+
self.failures[k] = self.failures.get(k, 0) + 1
|
|
321
|
+
if status == Status.NOT_FOUND.value and spec.name != "bash" \
|
|
322
|
+
and env.instruction != make(Status.NOT_FOUND, reason=REASON_UNKNOWN_TOOL).instruction:
|
|
323
|
+
for r in ctx.get("resources", []):
|
|
324
|
+
if r.kind == "path" and os.path.exists(r.value):
|
|
325
|
+
continue # e.g. edit_file "text not found": the file exists
|
|
326
|
+
if r not in self.missing:
|
|
327
|
+
self.missing.append(r)
|
|
328
|
+
|
|
329
|
+
def _record_outcome(self, spec, env: ToolResult, ctx) -> None:
|
|
330
|
+
eff: Optional[fx.Effect] = ctx.get("effect")
|
|
331
|
+
if ctx.get("intent_id"):
|
|
332
|
+
post = fx.file_hash(eff.file_path) if (eff and eff.file_path) else None
|
|
333
|
+
self.ledger.finish(ctx["intent_id"], status=env.status, receipt_id=env.receipt_id,
|
|
334
|
+
post_hash=post, message=env.message)
|
|
335
|
+
if spec.name == "bash" and ctx.get("writes"):
|
|
336
|
+
for path in dict.fromkeys(w.path for w in ctx["writes"]):
|
|
337
|
+
fe = fx.recorded_file_effect(path, ctx["writes"])
|
|
338
|
+
post = fx.file_hash(path)
|
|
339
|
+
if fe is None or post is None or post == ctx.get("pre", {}).get(path):
|
|
340
|
+
continue # nothing was written there (or nothing changed)
|
|
341
|
+
self.ledger.record(conversation=self.conversation, turn=self.turn_id,
|
|
342
|
+
surface=self.surface, tool="bash", effect="write",
|
|
343
|
+
target=fe.target, payload_hash=fe.payload_hash, key=fe.key,
|
|
344
|
+
status=Status.OK.value, receipt_id=new_receipt_id(),
|
|
345
|
+
ref=ctx.get("intent_id"), post_hash=post,
|
|
346
|
+
args=fx.summarize_args({"via": "bash", "path": path}),
|
|
347
|
+
message=f"bash wrote {path}")
|
|
348
|
+
|
|
349
|
+
def _log_refusal(self, spec, eff: Optional[fx.Effect], args, env: ToolResult) -> None:
|
|
350
|
+
"""Refusals of effectful calls go in the activity log too."""
|
|
351
|
+
if eff is None and spec.name != "bash":
|
|
352
|
+
return
|
|
353
|
+
try:
|
|
354
|
+
self.ledger.record(conversation=self.conversation, turn=self.turn_id,
|
|
355
|
+
surface=self.surface, tool=spec.name,
|
|
356
|
+
effect=eff.effect if eff else spec.effect,
|
|
357
|
+
target=eff.target if eff else spec.name,
|
|
358
|
+
payload_hash=eff.payload_hash if eff else None,
|
|
359
|
+
key=None, status=env.status, receipt_id=env.receipt_id,
|
|
360
|
+
args=fx.summarize_args(args), message=env.message)
|
|
361
|
+
except Exception as e:
|
|
362
|
+
log.warning("effect ledger: could not record refusal: %s", e)
|
|
363
|
+
|
|
364
|
+
# ── turn end ──
|
|
365
|
+
def stop_note(self) -> str:
|
|
366
|
+
return TOOL_CAP_NOTE.format(limit=self.call_cap,
|
|
367
|
+
names=", ".join(self.skipped) or "none")
|