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
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read-only egress rules (Vaughn's gate C-364: "a tool marked read-only must
|
|
3
|
+
never send data").
|
|
4
|
+
|
|
5
|
+
GET/HEAD alone is not enough: data can leave in a query string or through a
|
|
6
|
+
link preview. So:
|
|
7
|
+
|
|
8
|
+
- A read-only fetch may only target a URL that appears VERBATIM in what the
|
|
9
|
+
user wrote or in content the user gave it (a pasted message or an attached
|
|
10
|
+
document, which arrive inside the user's own message). Any other URL — one
|
|
11
|
+
the model built, or one it found inside an email, web page or file it read
|
|
12
|
+
— goes to the approval queue.
|
|
13
|
+
- An auto-tier fetch never reaches a loopback, private, link-local or other
|
|
14
|
+
non-public address without the owner's approval.
|
|
15
|
+
- Every Telegram message goes out with link previews off and every Slack post
|
|
16
|
+
with unfurls off (the payload fragments below; see tests/unit/
|
|
17
|
+
test_approval_egress.py for the repo-wide check).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ipaddress
|
|
22
|
+
import re
|
|
23
|
+
import socket
|
|
24
|
+
from typing import Iterable, List, Optional, Set
|
|
25
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
26
|
+
|
|
27
|
+
# ───────────────────────── messaging payload fragments ─────────────────────────
|
|
28
|
+
# Telegram Bot API 7.0+: link_preview_options replaces disable_web_page_preview.
|
|
29
|
+
TELEGRAM_NO_PREVIEW = {"link_preview_options": {"is_disabled": True}}
|
|
30
|
+
# Slack chat.postMessage and incoming webhooks.
|
|
31
|
+
SLACK_NO_UNFURL = {"unfurl_links": False, "unfurl_media": False}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def telegram_payload(payload: dict) -> dict:
|
|
35
|
+
"""`payload` with link previews off (a new dict)."""
|
|
36
|
+
out = dict(payload)
|
|
37
|
+
out.update(TELEGRAM_NO_PREVIEW)
|
|
38
|
+
return out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def slack_payload(payload: dict) -> dict:
|
|
42
|
+
"""`payload` with link and media unfurls off (a new dict)."""
|
|
43
|
+
out = dict(payload)
|
|
44
|
+
out.update(SLACK_NO_UNFURL)
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ───────────────────────── methods ─────────────────────────
|
|
49
|
+
READ_METHODS = frozenset({"GET", "HEAD"})
|
|
50
|
+
|
|
51
|
+
# Arguments of read-effect tools that name a URL the tool will fetch or hand
|
|
52
|
+
# to a service. Their values are checked against provenance.
|
|
53
|
+
URL_ARGS = frozenset({"url", "urls", "link", "href", "image_url", "src", "source_url"})
|
|
54
|
+
|
|
55
|
+
# ───────────────────────── URL provenance ─────────────────────────
|
|
56
|
+
_URL_RE = re.compile(r"""https?://[^\s<>"'`\])}]+""", re.IGNORECASE)
|
|
57
|
+
_BARE_RE = re.compile(r"""(?<![@\w.-])(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:/[^\s<>"'`\])}]*)?""",
|
|
58
|
+
re.IGNORECASE)
|
|
59
|
+
_TRAIL = ".,;:!?"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _strip_trailing(u: str) -> str:
|
|
63
|
+
while u and u[-1] in _TRAIL:
|
|
64
|
+
u = u[:-1]
|
|
65
|
+
return u
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normalize_url(url: str) -> Optional[str]:
|
|
69
|
+
"""Canonical form for comparison: lower-case scheme and host, default port
|
|
70
|
+
dropped, empty path -> '/', fragment dropped (it is never sent). The query
|
|
71
|
+
string is kept exactly: a different query is a different URL."""
|
|
72
|
+
try:
|
|
73
|
+
parts = urlsplit(str(url).strip())
|
|
74
|
+
except ValueError:
|
|
75
|
+
return None
|
|
76
|
+
scheme = (parts.scheme or "").lower()
|
|
77
|
+
if scheme not in ("http", "https") or not parts.hostname:
|
|
78
|
+
return None
|
|
79
|
+
host = parts.hostname.lower()
|
|
80
|
+
if ":" in host: # IPv6 literal
|
|
81
|
+
host = f"[{host}]"
|
|
82
|
+
try:
|
|
83
|
+
port = parts.port
|
|
84
|
+
except ValueError:
|
|
85
|
+
return None
|
|
86
|
+
if port and not ((scheme == "http" and port == 80) or (scheme == "https" and port == 443)):
|
|
87
|
+
host = f"{host}:{port}"
|
|
88
|
+
if parts.username or parts.password:
|
|
89
|
+
host = f"{parts.username or ''}{':' + parts.password if parts.password else ''}@{host}"
|
|
90
|
+
return urlunsplit((scheme, host, parts.path or "/", parts.query, ""))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _schemeless(norm: str) -> str:
|
|
94
|
+
return norm.split("://", 1)[1]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def provenance_urls(texts: Iterable[str]) -> Set[str]:
|
|
98
|
+
"""Every URL in the user's own words / user-given content, normalised.
|
|
99
|
+
A bare 'example.com/page' the user typed matches both http and https."""
|
|
100
|
+
found: Set[str] = set()
|
|
101
|
+
for t in texts or ():
|
|
102
|
+
if not isinstance(t, str) or not t:
|
|
103
|
+
continue
|
|
104
|
+
for m in _URL_RE.findall(t):
|
|
105
|
+
n = normalize_url(_strip_trailing(m))
|
|
106
|
+
if n:
|
|
107
|
+
found.add(n)
|
|
108
|
+
for m in _BARE_RE.findall(t):
|
|
109
|
+
m = _strip_trailing(m)
|
|
110
|
+
for scheme in ("https://", "http://"):
|
|
111
|
+
n = normalize_url(scheme + m)
|
|
112
|
+
if n:
|
|
113
|
+
found.add(n)
|
|
114
|
+
return found
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def url_in_provenance(url: str, texts: Iterable[str]) -> bool:
|
|
118
|
+
"""True when `url` (normalised) appears verbatim in `texts`."""
|
|
119
|
+
n = normalize_url(url)
|
|
120
|
+
if not n:
|
|
121
|
+
return False
|
|
122
|
+
return n in provenance_urls(texts)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ───────────────────────── private / non-public targets ─────────────────────────
|
|
126
|
+
_PRIVATE_SUFFIXES = (".localhost", ".local", ".internal", ".lan", ".home.arpa", ".intranet")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _ip_not_public(ip: ipaddress._BaseAddress) -> bool:
|
|
130
|
+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
|
|
131
|
+
ip = ip.ipv4_mapped
|
|
132
|
+
return bool(ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
|
|
133
|
+
or ip.is_multicast or ip.is_unspecified or not ip.is_global)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def resolve_ips(host: str) -> List[str]:
|
|
137
|
+
"""The host's addresses (tests replace this; a lookup failure is [])."""
|
|
138
|
+
try:
|
|
139
|
+
return sorted({ai[4][0] for ai in socket.getaddrinfo(host, None)})
|
|
140
|
+
except (OSError, UnicodeError):
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def is_private_target(url: str, resolve: bool = True) -> bool:
|
|
145
|
+
"""True when `url` points at loopback, a private or link-local range (the
|
|
146
|
+
cloud metadata address 169.254.169.254 included), or another non-public
|
|
147
|
+
address. Unparseable URLs count as private (fail closed)."""
|
|
148
|
+
try:
|
|
149
|
+
host = urlsplit(str(url).strip()).hostname
|
|
150
|
+
except ValueError:
|
|
151
|
+
return True
|
|
152
|
+
if not host:
|
|
153
|
+
return True
|
|
154
|
+
host = host.lower().rstrip(".")
|
|
155
|
+
if host == "localhost" or host.endswith(_PRIVATE_SUFFIXES) or "." not in host and ":" not in host:
|
|
156
|
+
# localhost, *.local, and single-label names (resolved on the LAN)
|
|
157
|
+
try:
|
|
158
|
+
return _ip_not_public(ipaddress.ip_address(host))
|
|
159
|
+
except ValueError:
|
|
160
|
+
return True
|
|
161
|
+
try:
|
|
162
|
+
return _ip_not_public(ipaddress.ip_address(host))
|
|
163
|
+
except ValueError:
|
|
164
|
+
pass
|
|
165
|
+
# Integer / hex / octal IPv4 spellings ("2130706433", "0x7f.1") are not
|
|
166
|
+
# hostnames a public site uses: treat them as private.
|
|
167
|
+
if re.fullmatch(r"[0-9x.]+", host):
|
|
168
|
+
return True
|
|
169
|
+
if not resolve:
|
|
170
|
+
return False
|
|
171
|
+
for ip in resolve_ips(host):
|
|
172
|
+
try:
|
|
173
|
+
if _ip_not_public(ipaddress.ip_address(ip.split("%", 1)[0])):
|
|
174
|
+
return True
|
|
175
|
+
except ValueError:
|
|
176
|
+
return True
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ───────────────────────── display ─────────────────────────
|
|
181
|
+
_SECRET_HEADER = re.compile(r"(?i)(authorization|cookie|token|secret|api[-_]?key|password|"
|
|
182
|
+
r"passwd|session|signature|credential|private)")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def mask_headers(headers) -> dict:
|
|
186
|
+
"""Headers for display: secret-looking values are masked (the approved
|
|
187
|
+
payload itself keeps the real values; its hash binds them)."""
|
|
188
|
+
if not isinstance(headers, dict):
|
|
189
|
+
return {}
|
|
190
|
+
out = {}
|
|
191
|
+
for k, v in headers.items():
|
|
192
|
+
if _SECRET_HEADER.search(str(k)):
|
|
193
|
+
s = str(v)
|
|
194
|
+
out[str(k)] = f"•••• (masked, {len(s)} chars)"
|
|
195
|
+
else:
|
|
196
|
+
out[str(k)] = v
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def urls_in_args(args: dict) -> List[str]:
|
|
201
|
+
"""The URL values among a tool call's URL arguments."""
|
|
202
|
+
out: List[str] = []
|
|
203
|
+
for k, v in (args or {}).items():
|
|
204
|
+
if k not in URL_ARGS:
|
|
205
|
+
continue
|
|
206
|
+
vals = v if isinstance(v, (list, tuple)) else [v]
|
|
207
|
+
for x in vals:
|
|
208
|
+
if isinstance(x, str) and x.strip():
|
|
209
|
+
out.append(x.strip())
|
|
210
|
+
return out
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The structured result envelope (tool standard S2).
|
|
3
|
+
|
|
4
|
+
Every tool result the model sees is exactly this object, serialised as
|
|
5
|
+
compact JSON in the tool-result content:
|
|
6
|
+
|
|
7
|
+
{"status": "not_found", "retryable": false,
|
|
8
|
+
"message": "File ~/notes/invoice-9912.txt does not exist.",
|
|
9
|
+
"instruction": "Does not exist. Do not retry or search for it. ...",
|
|
10
|
+
"data": null, "receipt_id": null}
|
|
11
|
+
|
|
12
|
+
`instruction` comes from a fixed table keyed by status (and, for a few
|
|
13
|
+
statuses, a fixed reason), so the model is always told what to do next and
|
|
14
|
+
never has to infer it from free text.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import uuid
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, ConfigDict
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Status(str, Enum):
|
|
27
|
+
OK = "ok"
|
|
28
|
+
NOT_FOUND = "not_found"
|
|
29
|
+
PERMISSION_DENIED = "permission_denied"
|
|
30
|
+
FAILED = "failed"
|
|
31
|
+
INVALID_ARGS = "invalid_args"
|
|
32
|
+
NEEDS_APPROVAL = "needs_approval" # ledger hold (wave 2); approval queue (wave 3)
|
|
33
|
+
DUPLICATE = "duplicate" # effect ledger (wave 2)
|
|
34
|
+
UNCERTAIN = "uncertain"
|
|
35
|
+
BUDGET_EXHAUSTED = "budget_exhausted"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Reasons that select a more specific fixed instruction for a status.
|
|
39
|
+
REASON_NOT_CONFIGURED = "not_configured"
|
|
40
|
+
REASON_SURFACE = "surface"
|
|
41
|
+
REASON_UNKNOWN_TOOL = "unknown_tool"
|
|
42
|
+
REASON_RETRYABLE = "retryable"
|
|
43
|
+
# Wave 2 (effect ledger, retry budgets).
|
|
44
|
+
REASON_CONFIRM = "confirm" # needs_approval: held by the ledger
|
|
45
|
+
REASON_RESOURCE_MISSING = "resource_missing" # budget_exhausted: resource was not_found
|
|
46
|
+
REASON_RETRY_BUDGET = "retry_budget" # budget_exhausted: retries used up
|
|
47
|
+
REASON_TURN_CAP = "turn_cap" # budget_exhausted: tool-call cap for the turn
|
|
48
|
+
|
|
49
|
+
INSTRUCTIONS = {
|
|
50
|
+
(Status.OK, None): "",
|
|
51
|
+
(Status.NOT_FOUND, None):
|
|
52
|
+
"Does not exist. Do not retry or search for it. Tell the user it was not found "
|
|
53
|
+
"and ask where it is.",
|
|
54
|
+
(Status.NOT_FOUND, REASON_UNKNOWN_TOOL):
|
|
55
|
+
"This tool does not exist. Do not call it again. Tell the user you cannot do this here.",
|
|
56
|
+
(Status.PERMISSION_DENIED, None):
|
|
57
|
+
"Access was denied. Do not retry or work around it with another tool. Tell the user "
|
|
58
|
+
"you could not do it and why.",
|
|
59
|
+
(Status.PERMISSION_DENIED, REASON_NOT_CONFIGURED):
|
|
60
|
+
"This is not set up on this installation. Do not retry. Tell the user it is not set "
|
|
61
|
+
"up and what needs to be connected.",
|
|
62
|
+
(Status.PERMISSION_DENIED, REASON_SURFACE):
|
|
63
|
+
"This tool is not available in this conversation and was NOT run. Do not retry or "
|
|
64
|
+
"use another tool to do the same thing. Tell the user you cannot do this here.",
|
|
65
|
+
(Status.FAILED, None):
|
|
66
|
+
"It failed and nothing was done. Do not retry. Tell the user it failed and give the "
|
|
67
|
+
"reason from message.",
|
|
68
|
+
(Status.FAILED, REASON_RETRYABLE):
|
|
69
|
+
"It failed with a temporary error. You may retry once; if it fails again, tell the "
|
|
70
|
+
"user it failed and why.",
|
|
71
|
+
(Status.INVALID_ARGS, None):
|
|
72
|
+
"The arguments were invalid and nothing ran. Fix the arguments named in message and "
|
|
73
|
+
"call the tool once more. If you do not have the information, ask the user.",
|
|
74
|
+
(Status.NEEDS_APPROVAL, None):
|
|
75
|
+
"Not done yet: it is waiting for the user's approval. Tell the user exactly what is "
|
|
76
|
+
"queued. Never say it was done.",
|
|
77
|
+
(Status.NEEDS_APPROVAL, REASON_CONFIRM):
|
|
78
|
+
"It was NOT done and nothing is queued. Tell the user what you were about to do and "
|
|
79
|
+
"why it was held (see message), and ask them. Do not call it again in this reply.",
|
|
80
|
+
(Status.DUPLICATE, None):
|
|
81
|
+
"This was already done earlier and was not repeated. Tell the user it was already "
|
|
82
|
+
"done. Do not call it again.",
|
|
83
|
+
(Status.UNCERTAIN, None):
|
|
84
|
+
"It may or may not have happened. Do not retry. Tell the user the outcome is "
|
|
85
|
+
"uncertain and offer to check.",
|
|
86
|
+
(Status.BUDGET_EXHAUSTED, None):
|
|
87
|
+
"The runtime refused another attempt; it was NOT run. Do not call it again. Tell the "
|
|
88
|
+
"user what you tried and what did not work.",
|
|
89
|
+
(Status.BUDGET_EXHAUSTED, REASON_RESOURCE_MISSING):
|
|
90
|
+
"Stop. Tell the user what you tried and that it was not found.",
|
|
91
|
+
(Status.BUDGET_EXHAUSTED, REASON_RETRY_BUDGET):
|
|
92
|
+
"It already failed and its retry budget is used up; it was NOT run. Do not call it "
|
|
93
|
+
"again. Tell the user what failed and why.",
|
|
94
|
+
(Status.BUDGET_EXHAUSTED, REASON_TURN_CAP):
|
|
95
|
+
"The tool-call limit for this message was reached; it was NOT run. Stop calling "
|
|
96
|
+
"tools. Tell the user what you did and what did not run.",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Default retryable flag per status. FAILED is per tool (see make()).
|
|
100
|
+
RETRYABLE = {
|
|
101
|
+
Status.OK: False,
|
|
102
|
+
Status.NOT_FOUND: False,
|
|
103
|
+
Status.PERMISSION_DENIED: False,
|
|
104
|
+
Status.FAILED: False,
|
|
105
|
+
Status.INVALID_ARGS: True, # once, with corrected arguments
|
|
106
|
+
Status.NEEDS_APPROVAL: False,
|
|
107
|
+
Status.DUPLICATE: False,
|
|
108
|
+
Status.UNCERTAIN: False,
|
|
109
|
+
Status.BUDGET_EXHAUSTED: False,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
ENVELOPE_KEYS = ("status", "retryable", "message", "instruction", "data", "receipt_id")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ToolResult(BaseModel):
|
|
116
|
+
"""The only thing a tool returns to the model."""
|
|
117
|
+
model_config = ConfigDict(extra="forbid", use_enum_values=True)
|
|
118
|
+
|
|
119
|
+
status: Status
|
|
120
|
+
retryable: bool
|
|
121
|
+
message: str
|
|
122
|
+
instruction: str
|
|
123
|
+
data: Any = None
|
|
124
|
+
receipt_id: Optional[str] = None
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def ok(self) -> bool:
|
|
128
|
+
return self.status == Status.OK.value
|
|
129
|
+
|
|
130
|
+
def to_dict(self) -> dict:
|
|
131
|
+
return {k: getattr(self, k) for k in ENVELOPE_KEYS}
|
|
132
|
+
|
|
133
|
+
def to_content(self, limit: int = 10000) -> str:
|
|
134
|
+
"""Compact JSON for the tool-result content, at most ~`limit` chars.
|
|
135
|
+
Over-long `data` is clipped (the JSON stays valid)."""
|
|
136
|
+
d = self.to_dict()
|
|
137
|
+
s = _dumps(d)
|
|
138
|
+
if len(s) <= limit:
|
|
139
|
+
return s
|
|
140
|
+
data = d["data"]
|
|
141
|
+
text = data if isinstance(data, str) else _dumps(data)
|
|
142
|
+
room = max(0, limit - (len(s) - len(_dumps(text))) - 60)
|
|
143
|
+
d["data"] = text[:room] + f"... [clipped: {len(text) - room} more characters]"
|
|
144
|
+
s = _dumps(d)
|
|
145
|
+
if len(s) > limit: # a huge message: clip it too
|
|
146
|
+
d["message"] = d["message"][:500]
|
|
147
|
+
d["data"] = (d["data"] or "")[: max(0, limit - 1000)]
|
|
148
|
+
s = _dumps(d)
|
|
149
|
+
return s
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _dumps(obj) -> str:
|
|
153
|
+
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def new_receipt_id() -> str:
|
|
157
|
+
return f"rcpt_{uuid.uuid4().hex[:16]}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def make(status: Status, message: str = "", *, data: Any = None,
|
|
161
|
+
receipt_id: Optional[str] = None, reason: Optional[str] = None,
|
|
162
|
+
retryable: Optional[bool] = None) -> ToolResult:
|
|
163
|
+
status = Status(status)
|
|
164
|
+
if reason == REASON_RETRYABLE and status == Status.FAILED and retryable is None:
|
|
165
|
+
retryable = True
|
|
166
|
+
instruction = INSTRUCTIONS.get((status, reason)) or INSTRUCTIONS[(status, None)]
|
|
167
|
+
if retryable is None:
|
|
168
|
+
retryable = RETRYABLE[status]
|
|
169
|
+
if status == Status.FAILED and retryable:
|
|
170
|
+
instruction = INSTRUCTIONS[(Status.FAILED, REASON_RETRYABLE)]
|
|
171
|
+
return ToolResult(status=status, retryable=bool(retryable), message=message or "",
|
|
172
|
+
instruction=instruction, data=data, receipt_id=receipt_id)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_content(content) -> Optional[dict]:
|
|
176
|
+
"""The envelope dict if `content` is a serialised envelope, else None."""
|
|
177
|
+
if isinstance(content, list): # Anthropic content blocks
|
|
178
|
+
content = "".join(b.get("text", "") if isinstance(b, dict) else str(b) for b in content)
|
|
179
|
+
if not isinstance(content, str) or not content.startswith("{"):
|
|
180
|
+
return None
|
|
181
|
+
try:
|
|
182
|
+
d = json.loads(content)
|
|
183
|
+
except (ValueError, TypeError):
|
|
184
|
+
return None
|
|
185
|
+
if isinstance(d, dict) and set(ENVELOPE_KEYS) <= set(d) and d.get("status") in {
|
|
186
|
+
s.value for s in Status}:
|
|
187
|
+
return d
|
|
188
|
+
return None
|