pal-code 0.1.0__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.
- pal/__init__.py +29 -0
- pal/__main__.py +3 -0
- pal/access_manager/__init__.py +16 -0
- pal/access_manager/components/__init__.py +14 -0
- pal/access_manager/components/access_control.py +41 -0
- pal/access_manager/components/base.py +39 -0
- pal/access_manager/components/intervention.py +31 -0
- pal/access_manager/components/privilege_registry.py +30 -0
- pal/access_manager/pipeline.py +68 -0
- pal/access_manager/utils/__init__.py +2 -0
- pal/agent_constraints/__init__.py +3 -0
- pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
- pal/agent_constraints/components/__init__.py +3 -0
- pal/agent_constraints/components/constraints.py +22 -0
- pal/agent_constraints/pipeline.py +22 -0
- pal/agent_constraints/utils/__init__.py +2 -0
- pal/agents/__init__.py +12 -0
- pal/agents/coding.py +14 -0
- pal/agents/docs.py +15 -0
- pal/chat/__init__.py +4 -0
- pal/chat/demo.py +231 -0
- pal/chat/providers.py +23 -0
- pal/chat/service.py +591 -0
- pal/chat/services/__init__.py +26 -0
- pal/chat/services/base.py +142 -0
- pal/chat/services/data.py +142 -0
- pal/chat/services/media.py +123 -0
- pal/chat/services/web.py +60 -0
- pal/chat/sessions.py +108 -0
- pal/chat/tools.py +446 -0
- pal/chat/uploads.py +81 -0
- pal/cli.py +502 -0
- pal/compression/__init__.py +12 -0
- pal/compression/components/__init__.py +64 -0
- pal/compression/components/adverbs.py +20 -0
- pal/compression/components/hedges.py +54 -0
- pal/compression/components/interjections.py +20 -0
- pal/compression/components/minifier.py +56 -0
- pal/compression/components/output_compressor.py +13 -0
- pal/compression/components/pleonasms.py +54 -0
- pal/compression/components/punctuations.py +36 -0
- pal/compression/components/stopwords.py +62 -0
- pal/compression/history.py +346 -0
- pal/compression/pipeline.py +58 -0
- pal/compression/utils/__init__.py +30 -0
- pal/compression/utils/negations.py +18 -0
- pal/compression/utils/nltk_setup.py +20 -0
- pal/compression/utils/pronouns.py +27 -0
- pal/compression/utils/proselint_setup.py +9 -0
- pal/compression/utils/spacy_setup.py +22 -0
- pal/config/default.yaml +163 -0
- pal/config.py +504 -0
- pal/context_manager/__init__.py +7 -0
- pal/context_manager/chat_context.py +117 -0
- pal/context_manager/components/__init__.py +5 -0
- pal/context_manager/components/assembler.py +75 -0
- pal/context_manager/components/context_cache.py +45 -0
- pal/context_manager/components/kv_bridge.py +16 -0
- pal/context_manager/components/pager.py +48 -0
- pal/context_manager/components/simple_context_manager.py +56 -0
- pal/context_manager/components/turns.py +88 -0
- pal/context_manager/pipeline.py +27 -0
- pal/context_manager/utils/__init__.py +18 -0
- pal/context_manager/utils/hashing.py +5 -0
- pal/context_manager/utils/relevance.py +59 -0
- pal/context_manager/utils/storage.py +37 -0
- pal/documents/__init__.py +4 -0
- pal/documents/components/__init__.py +0 -0
- pal/documents/components/chunker.py +79 -0
- pal/documents/components/index.py +155 -0
- pal/documents/utils/__init__.py +1 -0
- pal/errors.py +40 -0
- pal/input_converters/__init__.py +8 -0
- pal/input_converters/components/__init__.py +0 -0
- pal/input_converters/components/base.py +38 -0
- pal/input_converters/components/pandoc_converter.py +87 -0
- pal/input_converters/components/pdf_converter.py +46 -0
- pal/input_converters/components/pptx_converter.py +52 -0
- pal/input_converters/components/xlsx_converter.py +38 -0
- pal/input_converters/pipeline.py +72 -0
- pal/input_converters/utils/__init__.py +1 -0
- pal/input_converters/workspace.py +56 -0
- pal/input_savers/INPUT_RULES.md +2 -0
- pal/input_savers/__init__.py +5 -0
- pal/input_savers/components/__init__.py +0 -0
- pal/input_savers/components/base.py +40 -0
- pal/input_savers/components/methods.py +41 -0
- pal/input_savers/components/verifier.py +39 -0
- pal/input_savers/pipeline.py +154 -0
- pal/input_savers/utils/__init__.py +0 -0
- pal/input_savers/utils/guards.py +94 -0
- pal/knowledge_graph/__init__.py +10 -0
- pal/knowledge_graph/components/__init__.py +0 -0
- pal/knowledge_graph/components/base.py +50 -0
- pal/knowledge_graph/components/builder.py +54 -0
- pal/knowledge_graph/components/retriever.py +100 -0
- pal/knowledge_graph/components/workspace.py +116 -0
- pal/knowledge_graph/pipeline.py +215 -0
- pal/knowledge_graph/utils/__init__.py +1 -0
- pal/latency_tracker/__init__.py +3 -0
- pal/latency_tracker/pipeline.py +86 -0
- pal/llm_scheduler/__init__.py +11 -0
- pal/llm_scheduler/api.py +137 -0
- pal/llm_scheduler/code.py +28 -0
- pal/llm_scheduler/core.py +92 -0
- pal/llm_scheduler/local.py +215 -0
- pal/llm_scheduler/pipeline.py +42 -0
- pal/llm_scheduler/types.py +92 -0
- pal/llm_wrapper/__init__.py +34 -0
- pal/llm_wrapper/adapter.py +316 -0
- pal/llm_wrapper/components/__init__.py +23 -0
- pal/llm_wrapper/components/adapter_types.py +90 -0
- pal/llm_wrapper/components/base.py +195 -0
- pal/llm_wrapper/components/features.py +104 -0
- pal/llm_wrapper/components/litellm_wrapper.py +215 -0
- pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
- pal/llm_wrapper/modes.py +73 -0
- pal/llm_wrapper/pipeline.py +61 -0
- pal/llm_wrapper/utils/__init__.py +4 -0
- pal/llm_wrapper/utils/registry.py +49 -0
- pal/llm_wrapper/utils/token_counting.py +10 -0
- pal/local_llm/__init__.py +5 -0
- pal/local_llm/components/__init__.py +0 -0
- pal/local_llm/components/huggingface_local.py +217 -0
- pal/local_llm/components/local_llm.py +60 -0
- pal/logging_setup.py +18 -0
- pal/memory/__init__.py +7 -0
- pal/memory/components/__init__.py +6 -0
- pal/memory/components/lru_k.py +56 -0
- pal/memory/components/manager.py +98 -0
- pal/memory/components/note.py +54 -0
- pal/memory/components/storage_manager.py +60 -0
- pal/memory/pipeline.py +122 -0
- pal/memory/utils/__init__.py +3 -0
- pal/memory/utils/keyword_overlap.py +3 -0
- pal/memory_tracker/__init__.py +3 -0
- pal/memory_tracker/components/__init__.py +0 -0
- pal/memory_tracker/components/process.py +56 -0
- pal/memory_tracker/pipeline.py +44 -0
- pal/metrics/__init__.py +4 -0
- pal/metrics/components/__init__.py +4 -0
- pal/metrics/components/collector.py +140 -0
- pal/metrics/components/exporter.py +28 -0
- pal/metrics/pipeline.py +49 -0
- pal/metrics/utils/__init__.py +2 -0
- pal/notes_manager/__init__.py +5 -0
- pal/notes_manager/components/__init__.py +0 -0
- pal/notes_manager/components/privacy.py +14 -0
- pal/notes_manager/components/recall.py +31 -0
- pal/notes_manager/components/tidy.py +28 -0
- pal/notes_manager/pipeline.py +138 -0
- pal/orchestrator/__init__.py +26 -0
- pal/orchestrator/components/__init__.py +26 -0
- pal/orchestrator/components/agent.py +105 -0
- pal/orchestrator/components/cgroup_manager.py +209 -0
- pal/orchestrator/components/context_interrupt.py +220 -0
- pal/orchestrator/components/limits.py +9 -0
- pal/orchestrator/components/scheduler.py +69 -0
- pal/orchestrator/components/syscall_scheduler.py +200 -0
- pal/orchestrator/pipeline.py +228 -0
- pal/orchestrator/utils/__init__.py +4 -0
- pal/orchestrator/utils/device.py +164 -0
- pal/orchestrator/utils/protocol.py +32 -0
- pal/os_kernel/__init__.py +5 -0
- pal/os_kernel/filesystem.py +80 -0
- pal/os_kernel/network.py +146 -0
- pal/os_kernel/pipeline.py +175 -0
- pal/os_kernel/sysinfo.py +36 -0
- pal/paths.py +59 -0
- pal/pipeline.py +177 -0
- pal/py.typed +0 -0
- pal/redact.py +19 -0
- pal/sdk/__init__.py +33 -0
- pal/sdk/adapters.py +43 -0
- pal/sdk/agent.py +99 -0
- pal/sdk/components/__init__.py +14 -0
- pal/sdk/components/autogen_adapter.py +65 -0
- pal/sdk/components/autogen_client.py +98 -0
- pal/sdk/components/framework_adapter_base.py +69 -0
- pal/sdk/components/metagpt_adapter.py +46 -0
- pal/sdk/components/open_interpreter_adapter.py +47 -0
- pal/sdk/components/tool_catalog.py +44 -0
- pal/sdk/os_api.py +84 -0
- pal/sdk/patterns.py +129 -0
- pal/sdk/pipeline.py +240 -0
- pal/sdk/queries.py +68 -0
- pal/sdk/savers.py +88 -0
- pal/sdk/utils/__init__.py +2 -0
- pal/storage_manager/__init__.py +17 -0
- pal/storage_manager/components/__init__.py +19 -0
- pal/storage_manager/components/base.py +50 -0
- pal/storage_manager/components/local_file_backend.py +84 -0
- pal/storage_manager/components/storage_queue.py +91 -0
- pal/storage_manager/components/vector_store_backend.py +122 -0
- pal/storage_manager/pipeline.py +153 -0
- pal/storage_manager/utils/__init__.py +4 -0
- pal/storage_manager/utils/memory_adapter.py +60 -0
- pal/storage_manager/utils/sdk.py +93 -0
- pal/studio/__init__.py +0 -0
- pal/studio/builder.py +166 -0
- pal/studio/catalog.py +90 -0
- pal/studio/checks.py +129 -0
- pal/studio/clients.py +6 -0
- pal/studio/editor.py +137 -0
- pal/studio/files.py +6 -0
- pal/studio/models.py +6 -0
- pal/studio/plan.py +149 -0
- pal/studio/planner.py +205 -0
- pal/studio/policy.py +69 -0
- pal/studio/routes.py +254 -0
- pal/studio/runs.py +210 -0
- pal/task_router/__init__.py +11 -0
- pal/task_router/components/__init__.py +0 -0
- pal/task_router/components/answerer.py +37 -0
- pal/task_router/components/base.py +34 -0
- pal/task_router/components/classifier.py +40 -0
- pal/task_router/components/gates.py +23 -0
- pal/task_router/pipeline.py +61 -0
- pal/task_router/utils/__init__.py +1 -0
- pal/terminal/__init__.py +1 -0
- pal/terminal/backend.py +141 -0
- pal/terminal/bench.py +173 -0
- pal/terminal/cli.py +826 -0
- pal/terminal/clients.py +170 -0
- pal/terminal/commands.py +401 -0
- pal/terminal/decide.py +242 -0
- pal/terminal/files.py +55 -0
- pal/terminal/flow_cli.py +439 -0
- pal/terminal/hardware.py +199 -0
- pal/terminal/history.py +88 -0
- pal/terminal/keys.py +83 -0
- pal/terminal/local_models.py +261 -0
- pal/terminal/logo.py +117 -0
- pal/terminal/models.py +99 -0
- pal/terminal/policy.py +54 -0
- pal/terminal/project.py +413 -0
- pal/terminal/review.py +65 -0
- pal/terminal/security.py +239 -0
- pal/terminal/session.py +503 -0
- pal/terminal/setup.py +354 -0
- pal/terminal/syntax.py +98 -0
- pal/terminal/theme.py +37 -0
- pal/terminal/ui.py +647 -0
- pal/terminal/voice.py +137 -0
- pal/terminal/workflow.py +624 -0
- pal/token_tracker/__init__.py +3 -0
- pal/token_tracker/pipeline.py +54 -0
- pal/tokens.py +21 -0
- pal/tool_catalog.py +107 -0
- pal/tool_manager/__init__.py +17 -0
- pal/tool_manager/builtin.py +82 -0
- pal/tool_manager/components/__init__.py +19 -0
- pal/tool_manager/components/base.py +53 -0
- pal/tool_manager/components/conflict_resolver.py +27 -0
- pal/tool_manager/components/param_validator.py +41 -0
- pal/tool_manager/components/tool_call_queue.py +111 -0
- pal/tool_manager/components/tool_registry.py +60 -0
- pal/tool_manager/pipeline.py +80 -0
- pal/tool_manager/utils/__init__.py +3 -0
- pal/web/__init__.py +3 -0
- pal/web/app.py +276 -0
- pal/web/static/editor.js +219 -0
- pal/web/static/fonts/LICENSE-Inter.txt +3 -0
- pal/web/static/fonts/inter-400.woff2 +0 -0
- pal/web/static/fonts/inter-500.woff2 +0 -0
- pal/web/static/fonts/inter-600.woff2 +0 -0
- pal/web/static/fonts/inter-700.woff2 +0 -0
- pal/web/static/inter.css +5 -0
- pal/web/static/pal-logo-256.png +0 -0
- pal/web/static/pal-logo-32.png +0 -0
- pal/web/static/pal-logo-64.png +0 -0
- pal/web/static/studio.css +306 -0
- pal/web/static/studio.js +533 -0
- pal/web/static/voice.js +76 -0
- pal/web/templates/index.html +368 -0
- pal/web/templates/studio.html +120 -0
- pal_code-0.1.0.dist-info/METADATA +379 -0
- pal_code-0.1.0.dist-info/RECORD +282 -0
- pal_code-0.1.0.dist-info/WHEEL +5 -0
- pal_code-0.1.0.dist-info/entry_points.txt +2 -0
- pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- pal_code-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""What every third-party service tool shares: how it is described, how it reaches the network, and how its errors read.
|
|
2
|
+
|
|
3
|
+
A service tool is a function the model may ask for by name. Rules that hold for all of them:
|
|
4
|
+
* off unless named in `tools.services`, and offered only when the message looks like it needs the tool and its key is set;
|
|
5
|
+
* the key comes from the environment, is sent in a header where the service allows it, and is never in the tool's arguments,
|
|
6
|
+
its result, an error message or a log (the OS kernel interface logs a URL without its query string);
|
|
7
|
+
* every request goes through the OS kernel interface, so private addresses, redirects and sizes are checked there;
|
|
8
|
+
* the result is plain text for the model, and the tool prompt already says tool results are data, never instructions.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pal.errors import PalError
|
|
19
|
+
from pal.redact import redact
|
|
20
|
+
from pal.tool_manager.components.base import ToolParam
|
|
21
|
+
|
|
22
|
+
IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif"}
|
|
23
|
+
AUDIO_TYPES = {".wav": "audio/wav", ".mp3": "audio/mpeg", ".flac": "audio/flac", ".ogg": "audio/ogg", ".m4a": "audio/mp4"}
|
|
24
|
+
MEDIA_DIRS = ("uploads/", "generated/")
|
|
25
|
+
_RAPID_HOST = re.compile(r"^[a-z0-9][a-z0-9-]*(\.[a-z0-9-]+)*\.p\.rapidapi\.com$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ServiceSpec:
|
|
30
|
+
name: str
|
|
31
|
+
signature: str # as the model is told: name(arg, arg)
|
|
32
|
+
params: tuple # ToolParam, ...
|
|
33
|
+
run: Callable[..., str]
|
|
34
|
+
summary: str # one line, for the docs and `pal doctor`
|
|
35
|
+
cue: re.Pattern | None # does the message look like it needs this tool?
|
|
36
|
+
key_env: str | None = None # environment variable holding the key; None = no key needed
|
|
37
|
+
needs: str = "" # "image" or "audio": only offered when the session workspace holds such an upload
|
|
38
|
+
sends: str = "" # who receives what, in one phrase
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def param(name: str, typ: type, default: Any = ..., ) -> ToolParam:
|
|
42
|
+
return ToolParam(name, typ) if default is ... else ToolParam(name, typ, required=False, default=default)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def clamp(n: Any, lo: int, hi: int) -> int:
|
|
46
|
+
try:
|
|
47
|
+
return max(lo, min(hi, int(n)))
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
return lo
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def clean_text(value: Any, what: str, maxlen: int = 300) -> str:
|
|
53
|
+
s = re.sub(r"[\x00-\x1f]+", " ", str(value)).strip()
|
|
54
|
+
s = " ".join(s.split())
|
|
55
|
+
if not s:
|
|
56
|
+
raise PalError(f"give {what}")
|
|
57
|
+
return s[:maxlen]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def explain(service: str, status: int, text: str, key_env: str | None = None) -> str:
|
|
61
|
+
"""What an HTTP failure means, in words, without ever echoing a key."""
|
|
62
|
+
body = redact(" ".join(text.split()))[:200]
|
|
63
|
+
if status in (401, 403):
|
|
64
|
+
if "not subscribed" in text.lower():
|
|
65
|
+
return f"{service}: your key is not subscribed to this RapidAPI listing. Subscribe to it on RapidAPI, or set tools.rapidapi_hosts for a listing you have"
|
|
66
|
+
return f"{service} rejected the key{f' in {key_env}' if key_env else ''}; check that it is valid and allowed to use this API"
|
|
67
|
+
if status == 402:
|
|
68
|
+
return f"{service}: payment is required or the quota is used up"
|
|
69
|
+
if status == 404:
|
|
70
|
+
return f"{service}: nothing found, or the API address is no longer valid (HTTP 404)"
|
|
71
|
+
if status == 410:
|
|
72
|
+
return f"{service}: this API has been retired (HTTP 410)"
|
|
73
|
+
if status == 429:
|
|
74
|
+
return f"rate limit or quota reached for {service}; wait and try again later"
|
|
75
|
+
if status >= 500:
|
|
76
|
+
return f"{service} is having problems (HTTP {status}); try again later"
|
|
77
|
+
return f"{service} refused the request (HTTP {status}): {body}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ServiceCtx:
|
|
81
|
+
"""What a service tool may use. Built fresh for each call."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, env: Mapping[str, str], cfg: Any, os_call: Callable[[str, dict], Any]):
|
|
84
|
+
self.env, self.cfg, self._os = env, cfg, os_call
|
|
85
|
+
self.saved: list[str] = [] # workspace files this call made, so the turn can offer them for download
|
|
86
|
+
|
|
87
|
+
def key(self, var: str) -> str:
|
|
88
|
+
value = (self.env.get(var) or "").strip()
|
|
89
|
+
if not value:
|
|
90
|
+
raise PalError(f"{var} is not set, so this service cannot be used")
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
def os(self, op: str, params: dict) -> Any:
|
|
94
|
+
return self._os(op, params)
|
|
95
|
+
|
|
96
|
+
def net(self, url: str, **params: Any) -> dict:
|
|
97
|
+
return self._os("net_request", {"url": url, **params})
|
|
98
|
+
|
|
99
|
+
def fetch_json(self, service: str, url: str, *, key_env: str | None = None, **params: Any) -> Any:
|
|
100
|
+
r = self.net(url, **params)
|
|
101
|
+
if r["status"] >= 400:
|
|
102
|
+
raise PalError(explain(service, r["status"], r.get("text", ""), key_env))
|
|
103
|
+
try:
|
|
104
|
+
return json.loads(r["text"])
|
|
105
|
+
except ValueError as e:
|
|
106
|
+
raise PalError(f"{service} returned something that is not JSON") from e
|
|
107
|
+
|
|
108
|
+
def rapid_host(self, tool: str, default: str) -> str:
|
|
109
|
+
host = str(self.cfg.rapidapi_hosts.get(tool, default)).strip().lower()
|
|
110
|
+
if not _RAPID_HOST.match(host):
|
|
111
|
+
raise PalError(f"tools.rapidapi_hosts[{tool}] must be a RapidAPI host such as {default}")
|
|
112
|
+
return host
|
|
113
|
+
|
|
114
|
+
def hf_model(self, tool: str, default: str) -> str:
|
|
115
|
+
model = str(self.cfg.hf_models.get(tool, default)).strip()
|
|
116
|
+
# each part starts with a letter or digit, so ".." can never walk up the address that carries the token
|
|
117
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)?", model):
|
|
118
|
+
raise PalError(f"tools.hf_models[{tool}] is not a valid model name")
|
|
119
|
+
return model
|
|
120
|
+
|
|
121
|
+
def media(self, kind: str) -> list[str]:
|
|
122
|
+
exts = IMAGE_TYPES if kind == "image" else AUDIO_TYPES
|
|
123
|
+
found: list[str] = []
|
|
124
|
+
for prefix in MEDIA_DIRS:
|
|
125
|
+
found += [p for p in self._os("file_list", {"prefix": prefix}) if "." in p and "." + p.rsplit(".", 1)[1].lower() in exts]
|
|
126
|
+
return found
|
|
127
|
+
|
|
128
|
+
def media_path(self, path: Any, kind: str) -> tuple[str, str]:
|
|
129
|
+
"""(path, content type) for a media file this session holds. Only uploads and generated files, only the right kind."""
|
|
130
|
+
p = str(path).strip()
|
|
131
|
+
exts = IMAGE_TYPES if kind == "image" else AUDIO_TYPES
|
|
132
|
+
ext = "." + p.rsplit(".", 1)[1].lower() if "." in p else ""
|
|
133
|
+
if not p.startswith(MEDIA_DIRS) or ext not in exts:
|
|
134
|
+
have = ", ".join(self.media(kind)) or "none yet"
|
|
135
|
+
raise PalError(f"give the path of an uploaded or generated {kind} file (for example uploads/name{next(iter(exts))}); available: {have}")
|
|
136
|
+
return p, exts[ext]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# -- when an answer is in a shape that is not recognised, show it trimmed instead of failing --------------------------------------------------
|
|
140
|
+
def excerpt(data: Any, n: int = 700) -> str:
|
|
141
|
+
text = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
142
|
+
return text if len(text) <= n else text[:n] + " ..."
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Reference services: currency, words and the moon.
|
|
2
|
+
|
|
3
|
+
* currency Frankfurter (central bank reference rates), no key. The paper's RapidAPI converter needs a key and a listing that may change.
|
|
4
|
+
* moon phase computed here, no network and no key: the phase of the moon is arithmetic.
|
|
5
|
+
* WordsAPI its real RapidAPI address and headers (checked against its own documentation); tools.rapidapi_hosts can name another host.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
from pal.chat.services.base import (
|
|
15
|
+
ServiceCtx,
|
|
16
|
+
ServiceSpec,
|
|
17
|
+
excerpt,
|
|
18
|
+
param,
|
|
19
|
+
)
|
|
20
|
+
from pal.errors import PalError
|
|
21
|
+
|
|
22
|
+
RAPID = "RAPIDAPI_KEY"
|
|
23
|
+
_C = re.I
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# -- currency (Frankfurter, no key) --------------------------------------------------------------------------------------------------
|
|
27
|
+
def currency_convert(ctx: ServiceCtx, amount: float, from_currency: str, to_currency: str) -> str:
|
|
28
|
+
try:
|
|
29
|
+
amount = float(amount)
|
|
30
|
+
except (TypeError, ValueError) as e:
|
|
31
|
+
raise PalError("amount must be a number") from e
|
|
32
|
+
if not math.isfinite(amount) or not 0 < amount <= 1e12:
|
|
33
|
+
raise PalError("amount must be a positive number")
|
|
34
|
+
codes = []
|
|
35
|
+
for c in (from_currency, to_currency):
|
|
36
|
+
c = str(c).strip().upper()
|
|
37
|
+
if not re.fullmatch(r"[A-Z]{3}", c):
|
|
38
|
+
raise PalError(f"{c!r} is not a three-letter currency code such as USD or INR")
|
|
39
|
+
codes.append(c)
|
|
40
|
+
base, quote_ = codes
|
|
41
|
+
if base == quote_:
|
|
42
|
+
return f"{amount:,.2f} {base} = {amount:,.2f} {quote_}"
|
|
43
|
+
data = ctx.fetch_json("Frankfurter", f"https://api.frankfurter.dev/v2/rate/{base}/{quote_}")
|
|
44
|
+
rate = float(data["rate"]) if isinstance(data, dict) and isinstance(data.get("rate"), (int, float)) else None
|
|
45
|
+
if rate is None:
|
|
46
|
+
raise PalError("Frankfurter returned no rate for that pair")
|
|
47
|
+
return (f"{amount:,.2f} {base} = {amount * rate:,.2f} {quote_} (1 {base} = {rate:g} {quote_}, reference rate of "
|
|
48
|
+
f"{data.get('date', 'today')}; not a bank or card rate)")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# -- words (WordsAPI on RapidAPI) -----------------------------------------------------------------------------------------------------
|
|
52
|
+
_WORD_INFO = ("all", "definitions", "synonyms", "antonyms", "rhymes", "examples", "pronunciation", "syllables", "frequency")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def words_api(ctx: ServiceCtx, word: str, info: str = "definitions") -> str:
|
|
56
|
+
w = " ".join(str(word).split())
|
|
57
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z' -]{0,39}", w): # too long is refused, never silently cut short
|
|
58
|
+
raise PalError("give a single English word or short phrase")
|
|
59
|
+
info = str(info).strip().lower()
|
|
60
|
+
if info not in _WORD_INFO:
|
|
61
|
+
raise PalError(f"info must be one of: {', '.join(_WORD_INFO)}")
|
|
62
|
+
host = ctx.rapid_host("words_api", "wordsapiv1.p.rapidapi.com")
|
|
63
|
+
url = f"https://{host}/words/{quote(w.lower())}" + ("" if info == "all" else f"/{info}")
|
|
64
|
+
d = ctx.fetch_json("WordsAPI", url, key_env=RAPID, headers={"X-RapidAPI-Key": ctx.key(RAPID), "X-RapidAPI-Host": host})
|
|
65
|
+
if not isinstance(d, dict):
|
|
66
|
+
return excerpt(d)
|
|
67
|
+
out: list[str] = []
|
|
68
|
+
if info in ("definitions", "all"):
|
|
69
|
+
rows = d.get("definitions") or d.get("results") or []
|
|
70
|
+
out += [f"({r.get('partOfSpeech', '?')}) {r['definition']}" for r in rows[:6] if isinstance(r, dict) and r.get("definition")]
|
|
71
|
+
if info in ("synonyms", "antonyms", "examples"):
|
|
72
|
+
if d.get(info):
|
|
73
|
+
out.append(f"{info}: " + ", ".join(map(str, d[info][:15])))
|
|
74
|
+
if info == "rhymes" and isinstance(d.get("rhymes"), dict):
|
|
75
|
+
out.append("rhymes: " + ", ".join(map(str, (d["rhymes"].get("all") or [])[:15])))
|
|
76
|
+
if info == "pronunciation" and d.get("pronunciation"):
|
|
77
|
+
p = d["pronunciation"]
|
|
78
|
+
out.append("pronunciation: " + (", ".join(f"{k} {v}" for k, v in p.items()) if isinstance(p, dict) else str(p)))
|
|
79
|
+
if info == "syllables" and isinstance(d.get("syllables"), dict):
|
|
80
|
+
out.append(f"syllables: {d['syllables'].get('count', '?')} ({'-'.join(d['syllables'].get('list', []))})")
|
|
81
|
+
if info == "frequency" and d.get("frequency") is not None:
|
|
82
|
+
out.append(f"frequency: {d['frequency']}")
|
|
83
|
+
if info == "all":
|
|
84
|
+
syn = sorted({s for r in (d.get("results") or []) if isinstance(r, dict) for s in (r.get("synonyms") or [])})[:12]
|
|
85
|
+
if syn:
|
|
86
|
+
out.append("synonyms: " + ", ".join(syn))
|
|
87
|
+
if not out:
|
|
88
|
+
return f"nothing found for {w!r}"
|
|
89
|
+
return f"{d.get('word', w)}:\n" + "\n".join(out)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# -- the moon (computed here) ---------------------------------------------------------------------------------------------------------
|
|
93
|
+
SYNODIC_DAYS = 29.530588853 # mean length of a lunation
|
|
94
|
+
_REFERENCE_NEW_MOON = datetime(2000, 1, 6, 18, 14, tzinfo=timezone.utc)
|
|
95
|
+
_PHASES = ((0.0339, "New Moon"), (0.2161, "Waxing Crescent"), (0.2839, "First Quarter"), (0.4661, "Waxing Gibbous"),
|
|
96
|
+
(0.5339, "Full Moon"), (0.7161, "Waning Gibbous"), (0.7839, "Last Quarter"), (0.9661, "Waning Crescent"), (1.01, "New Moon"))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def moon(when: datetime) -> dict:
|
|
100
|
+
"""The moon's phase from the mean lunar cycle. Against eight real new and full moons it was off by 13 hours at worst, so it is
|
|
101
|
+
right to about half a day; around the exact moment of a phase the name may be one day early or late."""
|
|
102
|
+
age = ((when - _REFERENCE_NEW_MOON).total_seconds() / 86400) % SYNODIC_DAYS
|
|
103
|
+
frac = age / SYNODIC_DAYS
|
|
104
|
+
name = next(n for limit, n in _PHASES if frac < limit)
|
|
105
|
+
return {"age_days": age, "phase": name, "illumination": (1 - math.cos(2 * math.pi * frac)) / 2,
|
|
106
|
+
"next_new": when + timedelta(days=SYNODIC_DAYS - age),
|
|
107
|
+
"next_full": when + timedelta(days=(SYNODIC_DAYS / 2 - age) % SYNODIC_DAYS)}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def moon_phase(ctx: ServiceCtx, date: str = "") -> str:
|
|
111
|
+
text = str(date).strip()
|
|
112
|
+
if text:
|
|
113
|
+
try:
|
|
114
|
+
when = datetime.strptime(text, "%Y-%m-%d").replace(hour=12, tzinfo=timezone.utc)
|
|
115
|
+
except ValueError as e:
|
|
116
|
+
raise PalError("give the date as YYYY-MM-DD, or leave it empty for today") from e
|
|
117
|
+
label = text
|
|
118
|
+
else:
|
|
119
|
+
when = datetime.now(timezone.utc)
|
|
120
|
+
label = when.strftime("%Y-%m-%d")
|
|
121
|
+
m = moon(when)
|
|
122
|
+
return (f"Moon on {label} (UTC): {m['phase']}, about {m['illumination'] * 100:.0f}% lit, {m['age_days']:.1f} days into the cycle. "
|
|
123
|
+
f"Next new moon around {m['next_new']:%Y-%m-%d}, next full moon around {m['next_full']:%Y-%m-%d} "
|
|
124
|
+
"(a simple model, right to about half a day).")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
SERVICES = [
|
|
128
|
+
ServiceSpec("currency_convert", "currency_convert(amount, from_currency, to_currency)",
|
|
129
|
+
(param("amount", float), param("from_currency", str), param("to_currency", str)), currency_convert,
|
|
130
|
+
"convert money at central bank reference rates (Frankfurter)",
|
|
131
|
+
re.compile(r"\bcurrency\b|\bexchange rate\b|\bforex\b|\b(?:usd|eur|inr|gbp|jpy|aud|cad|chf|cny)\b|\b(?:dollars?|euros?|rupees?|pounds?|yen)\b", _C),
|
|
132
|
+
None, sends="the two currency codes go to Frankfurter"),
|
|
133
|
+
ServiceSpec("words_api", "words_api(word, info)", (param("word", str), param("info", str, "definitions")), words_api,
|
|
134
|
+
"definitions, synonyms, antonyms, rhymes, examples (WordsAPI)",
|
|
135
|
+
re.compile(r"\bsynonyms?\b|\bantonyms?\b|\bdefinition of\b|\bdefine\b|\bmeaning of\b|\brhymes? with\b|\bpronunciation\b|\bsyllables?\b|\bwhat does \w+ mean\b", _C),
|
|
136
|
+
RAPID, sends="the word goes to WordsAPI"),
|
|
137
|
+
ServiceSpec("moon_phase", "moon_phase(date)", (param("date", str, ""),), moon_phase,
|
|
138
|
+
"the moon's phase for a date, computed locally (no key, no network)",
|
|
139
|
+
re.compile(r"\bmoon\b|\blunar\b|\bfull moon\b|\bnew moon\b", _C), None, sends="nothing leaves this machine"),
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
__all__ = ["SERVICES", "moon", "SYNODIC_DAYS"]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Hugging Face media tools: describe an image, answer a question about it, make an image, speak text, transcribe audio.
|
|
2
|
+
|
|
3
|
+
They call the hf-inference route of Hugging Face Inference Providers (router.huggingface.co) with an HF_TOKEN. Uploaded images and
|
|
4
|
+
audio live in the session workspace; the model sees only their path, never the bytes. Made images and audio are saved to the
|
|
5
|
+
workspace under generated/ and can be downloaded from the page. Which models a route serves changes over time: every tool's model
|
|
6
|
+
can be set in tools.hf_models, and Hugging Face's own error text is passed on when a model is not available. These tools were
|
|
7
|
+
tested against fake servers only, never against Hugging Face.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from pal.chat.services.base import ServiceCtx, ServiceSpec, clean_text, excerpt, explain, param
|
|
15
|
+
from pal.errors import PalError
|
|
16
|
+
|
|
17
|
+
HF = "HF_TOKEN"
|
|
18
|
+
_C = re.I
|
|
19
|
+
_BASE = "https://router.huggingface.co/hf-inference/models/"
|
|
20
|
+
DEFAULT_MODELS = {
|
|
21
|
+
"image_caption": "Salesforce/blip-image-captioning-large",
|
|
22
|
+
"visual_question_answering": "dandelin/vilt-b32-finetuned-vqa",
|
|
23
|
+
"text_to_image": "black-forest-labs/FLUX.1-schnell",
|
|
24
|
+
"text_to_audio": "facebook/mms-tts-eng",
|
|
25
|
+
"speech_to_text": "openai/whisper-large-v3",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _call(ctx: ServiceCtx, tool: str, **params) -> dict:
|
|
30
|
+
model = ctx.hf_model(tool, DEFAULT_MODELS[tool])
|
|
31
|
+
headers = {"Authorization": "Bearer " + ctx.key(HF), **params.pop("headers", {})}
|
|
32
|
+
r = ctx.net(_BASE + model, headers=headers, **params)
|
|
33
|
+
if r["status"] == 503:
|
|
34
|
+
raise PalError(f"Hugging Face is loading {model}; try again in a minute" + _hf_detail(r))
|
|
35
|
+
if r["status"] >= 400:
|
|
36
|
+
raise PalError(explain(f"Hugging Face ({model})", r["status"], r.get("text", ""), HF))
|
|
37
|
+
return r
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _hf_detail(r: dict) -> str:
|
|
41
|
+
import json
|
|
42
|
+
try:
|
|
43
|
+
d = json.loads(r.get("text", ""))
|
|
44
|
+
return f" (estimated {d['estimated_time']:.0f}s)" if isinstance(d, dict) and d.get("estimated_time") else ""
|
|
45
|
+
except (ValueError, TypeError, KeyError):
|
|
46
|
+
return ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _json(r: dict, what: str):
|
|
50
|
+
import json
|
|
51
|
+
try:
|
|
52
|
+
return json.loads(r["text"])
|
|
53
|
+
except ValueError as e:
|
|
54
|
+
raise PalError(f"Hugging Face returned something that is not {what}") from e
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def image_caption(ctx: ServiceCtx, path: str) -> str:
|
|
58
|
+
p, ctype = ctx.media_path(path, "image")
|
|
59
|
+
r = _call(ctx, "image_caption", method="POST", body_path=p, content_type=ctype)
|
|
60
|
+
d = _json(r, "JSON")
|
|
61
|
+
text = d[0].get("generated_text") if isinstance(d, list) and d and isinstance(d[0], dict) else (d.get("generated_text") if isinstance(d, dict) else None)
|
|
62
|
+
return str(text).strip() if text else "no caption: " + excerpt(d)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def visual_question_answering(ctx: ServiceCtx, path: str, question: str) -> str:
|
|
66
|
+
p, _ = ctx.media_path(path, "image")
|
|
67
|
+
q = clean_text(question, "a question about the image", 300)
|
|
68
|
+
b64 = ctx.os("file_read_b64", {"path": p})["b64"]
|
|
69
|
+
r = _call(ctx, "visual_question_answering", method="POST", json={"inputs": {"image": b64, "question": q}})
|
|
70
|
+
d = _json(r, "JSON")
|
|
71
|
+
rows = [x for x in d if isinstance(x, dict) and x.get("answer")] if isinstance(d, list) else []
|
|
72
|
+
return "; ".join(f"{x['answer']} ({x.get('score', 0):.0%})" for x in rows[:3]) or "no answer: " + excerpt(d)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _saved(prefix: str, seed: str) -> str:
|
|
76
|
+
return f"generated/{prefix}-{hashlib.sha256(seed.encode()).hexdigest()[:8]}.{{ext}}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def text_to_image(ctx: ServiceCtx, prompt: str) -> str:
|
|
80
|
+
text = clean_text(prompt, "a description of the image", 500)
|
|
81
|
+
r = _call(ctx, "text_to_image", method="POST", json={"inputs": text}, save_as=_saved("image", text))
|
|
82
|
+
if not r.get("saved"):
|
|
83
|
+
raise PalError("Hugging Face did not return an image: " + (r.get("text") or r.get("content_type", ""))[:200])
|
|
84
|
+
ctx.saved.append(r["saved"])
|
|
85
|
+
return f"saved the image as {r['saved']} ({r['bytes']:,} bytes); the user can download it"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def text_to_audio(ctx: ServiceCtx, text: str) -> str:
|
|
89
|
+
t = clean_text(text, "the text to speak", 800)
|
|
90
|
+
r = _call(ctx, "text_to_audio", method="POST", json={"inputs": t}, save_as=_saved("speech", t))
|
|
91
|
+
if not r.get("saved"):
|
|
92
|
+
raise PalError("Hugging Face did not return audio: " + (r.get("text") or r.get("content_type", ""))[:200])
|
|
93
|
+
ctx.saved.append(r["saved"])
|
|
94
|
+
return f"saved the audio as {r['saved']} ({r['bytes']:,} bytes); the user can download it"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def speech_to_text(ctx: ServiceCtx, path: str) -> str:
|
|
98
|
+
p, ctype = ctx.media_path(path, "audio")
|
|
99
|
+
r = _call(ctx, "speech_to_text", method="POST", body_path=p, content_type=ctype)
|
|
100
|
+
d = _json(r, "JSON")
|
|
101
|
+
text = d.get("text") if isinstance(d, dict) else None
|
|
102
|
+
return str(text).strip() if text else "no speech found: " + excerpt(d)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
SERVICES = [
|
|
106
|
+
ServiceSpec("image_caption", "image_caption(path)", (param("path", str),), image_caption, "describe an uploaded image in one sentence",
|
|
107
|
+
re.compile(r"\bimage\b|\bpicture\b|\bphoto\b|\bpng\b|\bjpe?g\b|\bwebp\b|\bgif\b|\bcaption\b|\bdescribe\b|\bwhat(?:'s| is) in\b", _C),
|
|
108
|
+
HF, needs="image", sends="the image goes to Hugging Face"),
|
|
109
|
+
ServiceSpec("visual_question_answering", "visual_question_answering(path, question)", (param("path", str), param("question", str)),
|
|
110
|
+
visual_question_answering, "answer a question about an uploaded image",
|
|
111
|
+
re.compile(r"\bimage\b|\bpicture\b|\bphoto\b|\bpng\b|\bjpe?g\b|\bwebp\b|\bgif\b|\bin (?:the|this) (?:image|picture|photo)\b", _C),
|
|
112
|
+
HF, needs="image", sends="the image and your question go to Hugging Face"),
|
|
113
|
+
ServiceSpec("text_to_image", "text_to_image(prompt)", (param("prompt", str),), text_to_image, "make an image from a description",
|
|
114
|
+
re.compile(r"\b(?:draw|generate|create|make|paint)\b.{0,30}\b(?:image|picture|photo|illustration|logo)\b|\bimage of\b|\bpicture of\b", _C),
|
|
115
|
+
HF, sends="your description goes to Hugging Face"),
|
|
116
|
+
ServiceSpec("text_to_audio", "text_to_audio(text)", (param("text", str),), text_to_audio, "turn text into spoken audio",
|
|
117
|
+
re.compile(r"\bread (?:this )?aloud\b|\btext[- ]to[- ]speech\b|\bsay this\b|\bspeak\b|\bvoice\b|\baudio of\b|\bnarrat", _C),
|
|
118
|
+
HF, sends="your text goes to Hugging Face"),
|
|
119
|
+
ServiceSpec("speech_to_text", "speech_to_text(path)", (param("path", str),), speech_to_text,
|
|
120
|
+
"transcribe an uploaded audio file (voice activity recognition in the AIOS paper)",
|
|
121
|
+
re.compile(r"\baudio\b|\brecording\b|\bvoice\b|\btranscri\w+\b|\bspeech\b|\bwav\b|\bmp3\b|\bflac\b|\bogg\b|\bm4a\b", _C),
|
|
122
|
+
HF, needs="audio", sends="the audio goes to Hugging Face"),
|
|
123
|
+
]
|
pal/chat/services/web.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Search and lookup services: Google results and WolframAlpha.
|
|
2
|
+
|
|
3
|
+
Google's Custom Search JSON API is closed to new customers and ends on 1 January 2027, so the "Google search" tool reads the results
|
|
4
|
+
page through SerpApi, a third-party results service, which needs its own key and sends your query to it.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from urllib.parse import urlencode
|
|
10
|
+
|
|
11
|
+
from pal.chat.services.base import ServiceCtx, ServiceSpec, clamp, clean_text, param
|
|
12
|
+
from pal.errors import PalError
|
|
13
|
+
from pal.redact import redact
|
|
14
|
+
|
|
15
|
+
SERPAPI = "SERPAPI_API_KEY"
|
|
16
|
+
WOLFRAM = "WOLFRAM_APP_ID"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _serp(ctx: ServiceCtx, query: str, num: int) -> str:
|
|
20
|
+
q = clean_text(query, "a search query", 300)
|
|
21
|
+
n = clamp(num, 1, 10)
|
|
22
|
+
url = "https://serpapi.com/search.json?" + urlencode({"engine": "google", "q": q, "num": n, "api_key": ctx.key(SERPAPI)})
|
|
23
|
+
data = ctx.fetch_json("SerpApi", url, key_env=SERPAPI)
|
|
24
|
+
if isinstance(data, dict) and data.get("error"):
|
|
25
|
+
raise PalError("SerpApi: " + redact(str(data["error"]))[:200])
|
|
26
|
+
rows = (data.get("organic_results") if isinstance(data, dict) else None) or []
|
|
27
|
+
lines = []
|
|
28
|
+
for i, r in enumerate(rows[:n], 1):
|
|
29
|
+
if isinstance(r, dict) and r.get("title"):
|
|
30
|
+
snippet = " ".join(str(r.get("snippet", "")).split())[:240]
|
|
31
|
+
lines.append(f"{i}. {r['title']}" + (f" - {snippet}" if snippet else "") + (f" ({r['link']})" if r.get("link") else ""))
|
|
32
|
+
return "\n".join(lines) or f"no google results for {q!r}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def google_search(ctx: ServiceCtx, query: str, num: int = 5) -> str:
|
|
36
|
+
return _serp(ctx, query, num)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def wolfram_alpha(ctx: ServiceCtx, query: str) -> str:
|
|
40
|
+
q = clean_text(query, "a question or calculation in plain words", 300)
|
|
41
|
+
url = "https://www.wolframalpha.com/api/v1/llm-api?" + urlencode({"input": q, "maxchars": 2000})
|
|
42
|
+
r = ctx.net(url, headers={"Authorization": "Bearer " + ctx.key(WOLFRAM)})
|
|
43
|
+
if r["status"] in (400, 501):
|
|
44
|
+
raise PalError("Wolfram|Alpha did not understand that input; try rephrasing it")
|
|
45
|
+
if r["status"] >= 400:
|
|
46
|
+
from pal.chat.services.base import explain
|
|
47
|
+
raise PalError(explain("Wolfram|Alpha", r["status"], r.get("text", ""), WOLFRAM))
|
|
48
|
+
return (r.get("text") or "").strip() or "Wolfram|Alpha returned no answer"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_C = re.I
|
|
52
|
+
SERVICES = [
|
|
53
|
+
ServiceSpec("google_search", "google_search(query, num)", (param("query", str), param("num", int, 5)), google_search,
|
|
54
|
+
"Google results for a query (through SerpApi)", re.compile(r"\bgoogle\b|\bweb search\b|\bsearch (?:the )?(?:web|internet|online)\b|\blatest news\b|\bnews about\b", _C),
|
|
55
|
+
SERPAPI, sends="your query goes to SerpApi"),
|
|
56
|
+
ServiceSpec("wolfram_alpha", "wolfram_alpha(query)", (param("query", str),), wolfram_alpha,
|
|
57
|
+
"computed answers: maths, units, science, facts (Wolfram|Alpha LLM API)",
|
|
58
|
+
re.compile(r"\bwolfram\b|\bintegral\b|\bderivative\b|\bsolve\b|\bequation\b|\bhow (?:far|tall|heavy|old|big)\b|\bdistance (?:from|between)\b|\bpopulation of\b|\bconvert\b", _C),
|
|
59
|
+
WOLFRAM, sends="your query goes to Wolfram"),
|
|
60
|
+
]
|
pal/chat/sessions.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Chat sessions: one per browser tab. Bounded (max count, idle TTL) and thread-safe, so a
|
|
2
|
+
long-running server does not leak memory or disk."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections import OrderedDict
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from pal.chat.tools import ToolBox
|
|
14
|
+
from pal.context_manager import Turn
|
|
15
|
+
from pal.documents import DocumentIndex
|
|
16
|
+
from pal.errors import UserInputError
|
|
17
|
+
from pal.sdk.pipeline import AgentSDK
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_SESSION_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_session_id(value: str) -> str:
|
|
25
|
+
if not _SESSION_ID.match(value or ""):
|
|
26
|
+
raise UserInputError("invalid session id (1-64 letters, digits, - or _)")
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Session:
|
|
32
|
+
id: str
|
|
33
|
+
agent_id: str
|
|
34
|
+
sdk: AgentSDK
|
|
35
|
+
docs: DocumentIndex | None
|
|
36
|
+
toolbox: ToolBox
|
|
37
|
+
turns: list[Turn] = field(default_factory=list)
|
|
38
|
+
lock: threading.RLock = field(default_factory=threading.RLock)
|
|
39
|
+
created: float = field(default_factory=time.time)
|
|
40
|
+
last_used: float = field(default_factory=time.time)
|
|
41
|
+
on_close: Callable[[Session], None] | None = None
|
|
42
|
+
|
|
43
|
+
def touch(self) -> None:
|
|
44
|
+
self.last_used = time.time()
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
if self.on_close:
|
|
48
|
+
try:
|
|
49
|
+
self.on_close(self)
|
|
50
|
+
except Exception:
|
|
51
|
+
log.exception("session cleanup failed for %s", self.id[:8])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SessionStore:
|
|
55
|
+
def __init__(self, factory: Callable[[str], Session], max_sessions: int = 200, ttl_seconds: float = 4 * 3600):
|
|
56
|
+
self._factory = factory
|
|
57
|
+
self.max_sessions = max_sessions
|
|
58
|
+
self.ttl = ttl_seconds
|
|
59
|
+
self._lock = threading.Lock()
|
|
60
|
+
self._sessions: OrderedDict[str, Session] = OrderedDict()
|
|
61
|
+
|
|
62
|
+
def get_or_create(self, session_id: str) -> Session:
|
|
63
|
+
validate_session_id(session_id)
|
|
64
|
+
evicted: list[Session] = []
|
|
65
|
+
with self._lock:
|
|
66
|
+
evicted += self._expire_locked()
|
|
67
|
+
sess = self._sessions.get(session_id)
|
|
68
|
+
if sess is None:
|
|
69
|
+
sess = self._factory(session_id)
|
|
70
|
+
self._sessions[session_id] = sess
|
|
71
|
+
while len(self._sessions) > self.max_sessions:
|
|
72
|
+
_, old = self._sessions.popitem(last=False)
|
|
73
|
+
evicted.append(old)
|
|
74
|
+
self._sessions.move_to_end(session_id)
|
|
75
|
+
sess.touch()
|
|
76
|
+
for old in evicted: # cleanup outside the store lock
|
|
77
|
+
with old.lock:
|
|
78
|
+
old.close()
|
|
79
|
+
return sess
|
|
80
|
+
|
|
81
|
+
def _expire_locked(self) -> list[Session]:
|
|
82
|
+
now = time.time()
|
|
83
|
+
gone = [k for k, s in self._sessions.items() if now - s.last_used > self.ttl]
|
|
84
|
+
return [self._sessions.pop(k) for k in gone]
|
|
85
|
+
|
|
86
|
+
def remove(self, session_id: str) -> bool:
|
|
87
|
+
with self._lock:
|
|
88
|
+
sess = self._sessions.pop(session_id, None)
|
|
89
|
+
if sess is None:
|
|
90
|
+
return False
|
|
91
|
+
with sess.lock:
|
|
92
|
+
sess.close()
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
def get(self, session_id: str) -> Session | None:
|
|
96
|
+
with self._lock:
|
|
97
|
+
return self._sessions.get(session_id)
|
|
98
|
+
|
|
99
|
+
def __len__(self) -> int:
|
|
100
|
+
with self._lock:
|
|
101
|
+
return len(self._sessions)
|
|
102
|
+
|
|
103
|
+
def close_all(self) -> None:
|
|
104
|
+
with self._lock:
|
|
105
|
+
sessions, self._sessions = list(self._sessions.values()), OrderedDict()
|
|
106
|
+
for s in sessions:
|
|
107
|
+
with s.lock:
|
|
108
|
+
s.close()
|