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
pal/chat/providers.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""API providers the chat can talk to. Local models are never chat models: they only route, reduce
|
|
2
|
+
and verify. API keys are used in memory for the request and never logged or stored."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from pal.errors import UserInputError
|
|
8
|
+
from pal.llm_wrapper.components.base import WrapperConfig
|
|
9
|
+
from pal.llm_wrapper.modes import PROVIDER_KEYS
|
|
10
|
+
from pal.llm_wrapper.utils.registry import build_wrapper
|
|
11
|
+
|
|
12
|
+
API_PROVIDERS = PROVIDER_KEYS # the table lives with the two modes (llm_wrapper/modes.py)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_api_core(provider: str, model: str, api_key: str | None = None):
|
|
16
|
+
if provider not in API_PROVIDERS:
|
|
17
|
+
raise UserInputError(f"unsupported provider {provider!r}; API models only: {', '.join(API_PROVIDERS)}")
|
|
18
|
+
if not model:
|
|
19
|
+
raise UserInputError("choose a model")
|
|
20
|
+
key = api_key or os.environ.get(API_PROVIDERS[provider])
|
|
21
|
+
if not key:
|
|
22
|
+
raise UserInputError(f"no API key: paste one in settings or set {API_PROVIDERS[provider]}")
|
|
23
|
+
return build_wrapper(WrapperConfig(backend=provider, model=model, api_key=key))
|
pal/chat/service.py
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""One chat turn, end to end.
|
|
2
|
+
|
|
3
|
+
message -> [direct answer: arithmetic / time, no model]
|
|
4
|
+
-> [route: local LLM answers easy tasks itself]
|
|
5
|
+
-> API path: reduce input (local LLM) -> attach documents / project graph excerpts / memory
|
|
6
|
+
-> page history (last turns + relevant older ones) -> API model (+ read-only tool loop)
|
|
7
|
+
-> record history, memory, metrics
|
|
8
|
+
|
|
9
|
+
Everything the chat touches goes through the AgentSDK (chat, memory, storage, tools), which sits on
|
|
10
|
+
the storage, memory and tool managers. Extra context (documents, graph excerpts, memory) is added to the
|
|
11
|
+
request but never stored in the history, so it is not resent on later turns.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import asdict, dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from pal.access_manager.pipeline import AccessManagerPipeline
|
|
25
|
+
from pal.chat.providers import build_api_core
|
|
26
|
+
from pal.chat.services import selected
|
|
27
|
+
from pal.chat.sessions import Session, SessionStore, validate_session_id
|
|
28
|
+
from pal.chat.tools import ToolBox, direct_answer, strip_tool_tags
|
|
29
|
+
from pal.config import Settings, load_settings
|
|
30
|
+
from pal.context_manager import Turn, TurnPager
|
|
31
|
+
from pal.context_manager.chat_context import ChatContext
|
|
32
|
+
from pal.documents import DocumentIndex
|
|
33
|
+
from pal.errors import DependencyMissingError, PalError, ProviderError, UserInputError
|
|
34
|
+
from pal.input_savers.components.base import SaverResult
|
|
35
|
+
from pal.input_savers.pipeline import InputSaverPipeline
|
|
36
|
+
from pal.knowledge_graph import GRAPH_NOTICE, KnowledgeGraph
|
|
37
|
+
from pal.llm_scheduler import LLMScheduler, TaskClass, TurnStats, current_stats, is_rate_limit
|
|
38
|
+
from pal.llm_wrapper.components.base import LLMResponse, LLMSyscall, UnavailableWrapper, WrapperConfig
|
|
39
|
+
from pal.llm_wrapper.components.openrouter_wrapper import fetch_models, search_models
|
|
40
|
+
from pal.llm_wrapper.utils.registry import build_wrapper
|
|
41
|
+
from pal.memory.pipeline import MemoryPipeline
|
|
42
|
+
from pal.memory_tracker import dir_bytes
|
|
43
|
+
from pal.metrics import MetricsPipeline
|
|
44
|
+
from pal.metrics.components.collector import TurnMetric
|
|
45
|
+
from pal.notes_manager import NotesManager
|
|
46
|
+
from pal.os_kernel import OSKernel
|
|
47
|
+
from pal.paths import data_dir, safe_id
|
|
48
|
+
from pal.redact import redact
|
|
49
|
+
from pal.sdk.agent import AgentSpec, NativeAgent, agent_key, get_spec
|
|
50
|
+
from pal.sdk.pipeline import AgentSDK
|
|
51
|
+
from pal.storage_manager.pipeline import StorageManagerPipeline
|
|
52
|
+
from pal.task_router import API_NOTICE, LOCAL_TAG, RouteDecision, TaskRouter
|
|
53
|
+
from pal.tokens import estimate_tokens, make_counter
|
|
54
|
+
from pal.tool_manager.pipeline import ToolManagerPipeline
|
|
55
|
+
|
|
56
|
+
log = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
DOCS_NOTICE = "Attached files are indexed. A request may end with <documents> holding the parts relevant to it."
|
|
59
|
+
PAGING_NOTICE = "Older turns may be omitted; the relevant ones are restored."
|
|
60
|
+
COMPRESS_NOTICE = ("Some older assistant replies are shown compressed ([compressed]): exact numbers, names, paths and code signatures "
|
|
61
|
+
"are kept; old code bodies and replaced drafts are left out. A <facts> block, when present, lists exact details "
|
|
62
|
+
"from older turns that are not shown.")
|
|
63
|
+
_PROJECT_KIND = "project"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class TurnResult:
|
|
68
|
+
reply: str
|
|
69
|
+
handled_by: str # "api" | "local" | "tool"
|
|
70
|
+
route_reason: str = ""
|
|
71
|
+
sent: str = "" # the last user message the API model received
|
|
72
|
+
input_tokens: int = 0
|
|
73
|
+
output_tokens: int = 0
|
|
74
|
+
saver: object = None
|
|
75
|
+
graph: Optional[dict] = None
|
|
76
|
+
documents: Optional[dict] = None
|
|
77
|
+
memory: Optional[dict] = None
|
|
78
|
+
context: Optional[dict] = None
|
|
79
|
+
tools: list = field(default_factory=list)
|
|
80
|
+
fallback: Optional[dict] = None # {"provider", "model", "reason"} when the fallback model answered
|
|
81
|
+
cost_usd: float = 0.0
|
|
82
|
+
files: list = field(default_factory=list) # files an agent saved to its workspace this turn
|
|
83
|
+
local_tokens: Optional[dict] = None
|
|
84
|
+
latency: dict = field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> dict:
|
|
87
|
+
return {f: getattr(self, f) for f in self.__dataclass_fields__}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_fallback_error(e: BaseException) -> bool:
|
|
91
|
+
"""Rate limits, quota, timeouts, connection failures and server errors: the kinds a second model can fix.
|
|
92
|
+
A bad key, a bad request or an unknown model would fail the same way on any provider, so they are not retried elsewhere."""
|
|
93
|
+
if is_rate_limit(e) or isinstance(e, (TimeoutError, ConnectionError)):
|
|
94
|
+
return True
|
|
95
|
+
for attr in ("status_code", "code"):
|
|
96
|
+
code = getattr(e, attr, None)
|
|
97
|
+
if isinstance(code, int) and code >= 500:
|
|
98
|
+
return True
|
|
99
|
+
return any(k in type(e).__name__.lower() for k in ("timeout", "connection", "serviceunavailable", "internalserver"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ChatService:
|
|
103
|
+
def __init__(self, settings: Optional[Settings] = None, local_core=None, verifier_core=None, graph=None,
|
|
104
|
+
storage: Optional[StorageManagerPipeline] = None, metrics: Optional[MetricsPipeline] = None,
|
|
105
|
+
agents: Optional[dict[str, type[NativeAgent]]] = None):
|
|
106
|
+
self.settings = settings or load_settings()
|
|
107
|
+
if agents is None:
|
|
108
|
+
from pal.agents import AGENTS # the application layer is plugged in here, at composition time
|
|
109
|
+
agents = AGENTS
|
|
110
|
+
self.agents = agents
|
|
111
|
+
s = self.settings
|
|
112
|
+
self.scheduler = LLMScheduler(s.scheduler) # API: first come first served per key. Local: preemptive priority.
|
|
113
|
+
self._local = self.scheduler.wrap_local(local_core)
|
|
114
|
+
self._verifier = self.scheduler.wrap_local(verifier_core)
|
|
115
|
+
self.metrics = metrics or MetricsPipeline(max_records=s.metrics.max_records, enabled=s.metrics.enabled)
|
|
116
|
+
self.graph = graph or KnowledgeGraph(s.knowledge_graph)
|
|
117
|
+
self.preload_docs: list[tuple[str, str]] = [] # (name, markdown) indexed into every new session: the project folder's documents and map
|
|
118
|
+
home = Path(s.storage_dir).expanduser() if s.storage_dir else data_dir()
|
|
119
|
+
self.storage = storage or StorageManagerPipeline(root=str(home / "storage"))
|
|
120
|
+
self.access = AccessManagerPipeline()
|
|
121
|
+
self.os_kernel = OSKernel(s.os_kernel, access=self.access, root=home / "workspaces")
|
|
122
|
+
selected(s.tools.services) # an unknown service name stops the start-up, with the list to choose from
|
|
123
|
+
self._memory_pipeline = MemoryPipeline(
|
|
124
|
+
store_path=None, block_limit=s.memory.block_limit, agent_id=f"profile-{safe_id(s.memory.profile)}",
|
|
125
|
+
storage_manager=self.storage)
|
|
126
|
+
self._memory_sdk = self._new_sdk(f"profile-{safe_id(s.memory.profile)}", ToolManagerPipeline())
|
|
127
|
+
self.notes = NotesManager(self._memory_sdk, s.memory, scheduler=self.scheduler)
|
|
128
|
+
self.pager = TurnPager(s.context.hot_turns, s.context.recall_turns, s.context.recall_threshold,
|
|
129
|
+
s.context.max_history_tokens)
|
|
130
|
+
self.context = ChatContext(s.context, self.pager, self.notes, self.graph, tokens=self.metrics.tokens, memory_tracker=self.metrics.memory)
|
|
131
|
+
self.sessions = SessionStore(self._new_session, s.server.max_sessions, s.server.session_ttl_minutes * 60)
|
|
132
|
+
mt = self.metrics.memory # what the Memory Tracker can report on
|
|
133
|
+
mt.register("notes", self.notes.stats)
|
|
134
|
+
mt.register("sessions", lambda: {"open": len(self.sessions), "max": s.server.max_sessions})
|
|
135
|
+
mt.register("knowledge_graph", self.graph.stats)
|
|
136
|
+
mt.register("storage", lambda: {"bytes": dir_bytes(home / "storage")})
|
|
137
|
+
try: # projects left behind by a crash or a failed request
|
|
138
|
+
self.graph.prune_stale(s.server.session_ttl_minutes * 60)
|
|
139
|
+
self.os_kernel.prune_stale("chat-", s.server.session_ttl_minutes * 60)
|
|
140
|
+
except Exception: # noqa: BLE001 (housekeeping must never stop startup)
|
|
141
|
+
log.exception("could not prune stale graph projects")
|
|
142
|
+
|
|
143
|
+
# -- construction helpers -----------------------------------------------------------------
|
|
144
|
+
def _new_sdk(self, agent_id: str, tools) -> AgentSDK:
|
|
145
|
+
return AgentSDK(agent_id=agent_id, llm_wrapper=None, storage_manager=self.storage, tool_manager=tools,
|
|
146
|
+
access_manager=self.access, memory=self._memory_pipeline, os_kernel=self.os_kernel)
|
|
147
|
+
|
|
148
|
+
def _new_session(self, session_id: str) -> Session:
|
|
149
|
+
s = self.settings
|
|
150
|
+
agent_id = f"chat-{safe_id(session_id)}"
|
|
151
|
+
box = ToolBox(agent_id, s.tools, estimate_tokens, session_id=session_id, os_kernel=self.os_kernel, env=os.environ)
|
|
152
|
+
sdk = self._new_sdk(agent_id, box.manager)
|
|
153
|
+
docs = DocumentIndex(sdk, s.documents, estimate_tokens) if s.documents.enabled else None
|
|
154
|
+
if docs is not None:
|
|
155
|
+
for name, text in self.preload_docs:
|
|
156
|
+
try:
|
|
157
|
+
docs.add(name, text)
|
|
158
|
+
except Exception: # noqa: BLE001 (one unreadable document never stops a session)
|
|
159
|
+
log.exception("could not index %s", name)
|
|
160
|
+
box.bind(docs=docs, graph=self.graph if self.graph.enabled else None)
|
|
161
|
+
box.code_run = self.scheduler.code.run # converting and graph building run in the code slots, beside the LLM lines
|
|
162
|
+
sess = Session(id=session_id, agent_id=agent_id, sdk=sdk, docs=docs, toolbox=box)
|
|
163
|
+
sess.on_close = self._close_session
|
|
164
|
+
return sess
|
|
165
|
+
|
|
166
|
+
def _close_session(self, sess: Session) -> None:
|
|
167
|
+
sess.sdk.close()
|
|
168
|
+
self.graph.reset(sess.id)
|
|
169
|
+
self.os_kernel.drop_workspace(sess.agent_id) # uploaded and generated files do not outlive the chat
|
|
170
|
+
|
|
171
|
+
# -- cores --------------------------------------------------------------------------------------
|
|
172
|
+
@property
|
|
173
|
+
def local_off(self) -> bool:
|
|
174
|
+
"""True on a computer without a GPU (local_llm.enabled: false): no local model at all, so routing, input trimming, checking and
|
|
175
|
+
summaries by the local model are off and every request goes to the API model as typed. Savers that need no model (paging,
|
|
176
|
+
document and code excerpts, memory) still work."""
|
|
177
|
+
return self.settings.local_llm.get("enabled", True) is False
|
|
178
|
+
|
|
179
|
+
def _build_local(self, cfg: dict):
|
|
180
|
+
cfg = {k: v for k, v in cfg.items() if k != "enabled"}
|
|
181
|
+
try:
|
|
182
|
+
return self.scheduler.wrap_local(build_wrapper(WrapperConfig(**cfg)))
|
|
183
|
+
except DependencyMissingError as e: # fail safe: everything that asks the local model falls back to the API
|
|
184
|
+
log.warning("the local model cannot be set up: %s", e)
|
|
185
|
+
return self.scheduler.wrap_local(UnavailableWrapper(str(e)))
|
|
186
|
+
|
|
187
|
+
def local_core(self):
|
|
188
|
+
if self._local is None:
|
|
189
|
+
self._local = self._build_local(self.settings.local_llm)
|
|
190
|
+
return self._local
|
|
191
|
+
|
|
192
|
+
def verifier_core(self):
|
|
193
|
+
if self._verifier is None:
|
|
194
|
+
v = self.settings.verifier_llm
|
|
195
|
+
self._verifier = self._build_local(v) if v else self.local_core()
|
|
196
|
+
return self._verifier
|
|
197
|
+
|
|
198
|
+
def api_core(self, provider: str, model: str, api_key: Optional[str] = None):
|
|
199
|
+
return build_api_core(provider, model, api_key)
|
|
200
|
+
|
|
201
|
+
def model_catalogue(self, text: str = "", free_only: bool = False, limit: int = 50) -> list[dict]:
|
|
202
|
+
"""OpenRouter's models for the Explore mode: search by words in the name, optionally only free ones. `self.catalogue_fetch` replaces the
|
|
203
|
+
network read (for tests)."""
|
|
204
|
+
try:
|
|
205
|
+
models = fetch_models(getattr(self, "catalogue_fetch", None))
|
|
206
|
+
except Exception as e: # noqa: BLE001
|
|
207
|
+
raise ProviderError(f"could not read OpenRouter's model list ({type(e).__name__}); you can still type a model name yourself") from e
|
|
208
|
+
return [{**asdict(m), "label": m.label()} for m in search_models(models, text, free_only, limit)]
|
|
209
|
+
|
|
210
|
+
def router(self) -> TaskRouter:
|
|
211
|
+
cfg = self.settings.task_routing
|
|
212
|
+
if self.local_off: # no local model: nothing is answered locally, everything goes to the API model
|
|
213
|
+
import copy
|
|
214
|
+
off = copy.copy(TaskRouter(None, cfg).cfg)
|
|
215
|
+
off.enabled = False
|
|
216
|
+
return TaskRouter(None, off)
|
|
217
|
+
return TaskRouter(self.local_core() if cfg.enabled else None, cfg)
|
|
218
|
+
|
|
219
|
+
# -- prompt pieces ---------------------------------------------------------------------------------
|
|
220
|
+
def output_config(self) -> str:
|
|
221
|
+
return self.settings.read_text_file(self.settings.output_config)
|
|
222
|
+
|
|
223
|
+
def system_prompt(self, with_output_config: bool = True, local_turns: bool = False, graph_project: bool = False,
|
|
224
|
+
documents: bool = False, paged: bool = False, tools: str = "", agent_role: str = "",
|
|
225
|
+
compressed: bool = False) -> str:
|
|
226
|
+
prompt = self.settings.base_system_prompt
|
|
227
|
+
if agent_role:
|
|
228
|
+
prompt += f"\n\n{agent_role}"
|
|
229
|
+
cfg = self.output_config() if with_output_config else ""
|
|
230
|
+
if cfg:
|
|
231
|
+
prompt += f"\n\n{cfg}"
|
|
232
|
+
for on, text in ((local_turns, API_NOTICE), (graph_project, GRAPH_NOTICE), (documents, DOCS_NOTICE),
|
|
233
|
+
(paged, PAGING_NOTICE), (compressed, COMPRESS_NOTICE), (bool(tools), tools)):
|
|
234
|
+
if on:
|
|
235
|
+
prompt += f"\n\n{text}"
|
|
236
|
+
return prompt
|
|
237
|
+
|
|
238
|
+
def saver(self, counter) -> InputSaverPipeline:
|
|
239
|
+
rules = self.settings.read_text_file(self.settings.input_rules) or None
|
|
240
|
+
return InputSaverPipeline(self.local_core(), self.verifier_core(), counter, rules_text=rules)
|
|
241
|
+
|
|
242
|
+
def reduce(self, counter, text: str, mode: Optional[str], methods: Optional[list], pasted: list):
|
|
243
|
+
"""Every chatbox input goes through the local LLM here, before any API call."""
|
|
244
|
+
if self.local_off: # no local model: the message goes as typed
|
|
245
|
+
n = counter(text) if callable(counter) else estimate_tokens(text)
|
|
246
|
+
return SaverResult(text, text, [], n, n, "off")
|
|
247
|
+
mode = mode or self.settings.input_mode
|
|
248
|
+
saver = self.saver(counter)
|
|
249
|
+
if mode == "steps":
|
|
250
|
+
return saver.run(text, self.enabled_methods(methods), pasted)
|
|
251
|
+
return saver.run_rules(text, pasted)
|
|
252
|
+
|
|
253
|
+
def enabled_methods(self, requested: Optional[list]) -> list:
|
|
254
|
+
return [m for m in self.settings.input_methods if requested is None or m in requested]
|
|
255
|
+
|
|
256
|
+
# -- one turn ----------------------------------------------------------------------------------------
|
|
257
|
+
def send(self, session_id: str, provider: str, model: str, api_key: Optional[str], message: str,
|
|
258
|
+
attachments: list[dict], methods: Optional[list] = None, mode: Optional[str] = None,
|
|
259
|
+
pasted: Optional[list] = None, route: Optional[str] = None, ephemeral: bool = False,
|
|
260
|
+
agent: Optional[str] = None, allow_tools: Optional[frozenset] = None, query: Optional[str] = None) -> TurnResult:
|
|
261
|
+
"""`ephemeral` turns (the demo) read and write no memory and are not added to the metrics.
|
|
262
|
+
`agent` runs the turn as that agent: Gemini with the agent's own key, API route, its role and tools.
|
|
263
|
+
`allow_tools` limits the turn to those tools (an empty set means none); None leaves every enabled tool available.
|
|
264
|
+
`query` is what to look up memory, document passages and code excerpts with, when the message carries more than the person's own words
|
|
265
|
+
(the terminal adds the folder listing and the agent's instructions); None uses the message."""
|
|
266
|
+
spec = self._resolve_agent(agent)
|
|
267
|
+
if spec is not None:
|
|
268
|
+
provider, model, route = "gemini", self.settings.agents.model, "api"
|
|
269
|
+
api_key = agent_key(spec)
|
|
270
|
+
sid = validate_session_id(session_id or uuid.uuid4().hex)
|
|
271
|
+
if not (message or "").strip() and not attachments:
|
|
272
|
+
raise UserInputError("empty message")
|
|
273
|
+
sess = self.sessions.get_or_create(sid)
|
|
274
|
+
m = TurnMetric(route="api", session=sid[:8], provider=provider, model=model)
|
|
275
|
+
start = time.perf_counter()
|
|
276
|
+
cls = TaskClass.AGENT if spec is not None else TaskClass.CHAT
|
|
277
|
+
with sess.lock, self.scheduler.task(cls), self.scheduler.track() as stats:
|
|
278
|
+
try:
|
|
279
|
+
res = self._turn(sess, provider, model, api_key, message, attachments, methods, mode, pasted or [],
|
|
280
|
+
route, m, ephemeral, spec, allow_tools, query)
|
|
281
|
+
except Exception as e:
|
|
282
|
+
m.error = type(e).__name__
|
|
283
|
+
m.latency_total = time.perf_counter() - start
|
|
284
|
+
self.metrics.latency.finish(m)
|
|
285
|
+
self._apply_stats(m, stats)
|
|
286
|
+
if not ephemeral:
|
|
287
|
+
self.metrics.record_turn(m)
|
|
288
|
+
raise
|
|
289
|
+
self._apply_stats(m, stats)
|
|
290
|
+
self.notes.after_turn(ephemeral) # background: merging duplicate notes (OTHER class)
|
|
291
|
+
m.route = res.handled_by
|
|
292
|
+
m.latency_total = time.perf_counter() - start
|
|
293
|
+
self.metrics.latency.finish(m)
|
|
294
|
+
res.latency = {"total": round(m.latency_total, 3), "local": round(m.latency_local, 3),
|
|
295
|
+
"api": round(m.latency_api, 3), "tools": round(m.latency_tools, 3)}
|
|
296
|
+
if not ephemeral:
|
|
297
|
+
self.metrics.record_turn(m)
|
|
298
|
+
return res
|
|
299
|
+
|
|
300
|
+
def _summarize_text(self, text: str) -> str:
|
|
301
|
+
"""For the summarize_text tool: a short summary of `text` by the local model."""
|
|
302
|
+
core = self.local_core()
|
|
303
|
+
system = "Summarise the text in at most 150 words. Plain text, no preface."
|
|
304
|
+
return core.chat(LLMSyscall(messages=[{"role": "system", "content": system}, {"role": "user", "content": text}],
|
|
305
|
+
temperature=0.0, max_tokens=300)).text
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def _apply_stats(m: TurnMetric, stats: TurnStats) -> None:
|
|
309
|
+
m.wait_local, m.wait_api, m.local_pauses = round(stats.wait_local, 4), round(stats.wait_api, 4), stats.local_pauses
|
|
310
|
+
|
|
311
|
+
def _resolve_agent(self, agent: Optional[str]) -> Optional[AgentSpec]:
|
|
312
|
+
if not agent:
|
|
313
|
+
return None
|
|
314
|
+
if not self.settings.agents.enabled:
|
|
315
|
+
raise UserInputError("agents are turned off in the settings")
|
|
316
|
+
spec = get_spec(self.agents, agent)
|
|
317
|
+
if not agent_key(spec):
|
|
318
|
+
raise UserInputError(f"the {spec.name} needs a Gemini API key: set {spec.key_env} in your .env file")
|
|
319
|
+
return spec
|
|
320
|
+
|
|
321
|
+
def _remember(self, sid: str, message: str, ephemeral: bool = False) -> None:
|
|
322
|
+
if ephemeral:
|
|
323
|
+
return
|
|
324
|
+
self.notes.remember(sid, message)
|
|
325
|
+
|
|
326
|
+
def _turn(self, sess: Session, provider, model, api_key, message, attachments, methods, mode, pasted, route,
|
|
327
|
+
m: TurnMetric, ephemeral: bool = False, spec: Optional[AgentSpec] = None,
|
|
328
|
+
allow_tools: Optional[frozenset] = None, query: Optional[str] = None) -> TurnResult:
|
|
329
|
+
s = self.settings
|
|
330
|
+
sid = sess.id
|
|
331
|
+
sess.toolbox.allow = spec.tools if spec else allow_tools
|
|
332
|
+
sess.toolbox.saved_files = []
|
|
333
|
+
sess.toolbox.summarizer = None if self.local_off else self._summarize_text # the local model, in this turn's priority class
|
|
334
|
+
history_msgs = [x for t in sess.turns for x in (
|
|
335
|
+
{"role": "user", "content": t.user},
|
|
336
|
+
{"role": "assistant", "content": (f"{LOCAL_TAG} " if t.local else "") + t.assistant})]
|
|
337
|
+
project = self.graph.has_project(sid)
|
|
338
|
+
|
|
339
|
+
# 1. no model needed
|
|
340
|
+
if s.tools.enabled and s.tools.direct_answers and not attachments and route != "api":
|
|
341
|
+
with self.metrics.latency.stage(m, "direct"):
|
|
342
|
+
direct = direct_answer(message)
|
|
343
|
+
if direct is not None:
|
|
344
|
+
sess.turns.append(Turn(message, direct, local=True))
|
|
345
|
+
return TurnResult(reply=direct, handled_by="tool", route_reason="answered without a model")
|
|
346
|
+
|
|
347
|
+
# 2. routing
|
|
348
|
+
t0 = time.perf_counter()
|
|
349
|
+
router = self.router()
|
|
350
|
+
if project and route in (None, "", "auto") and router.enabled:
|
|
351
|
+
decision = RouteDecision("api", "a code project is loaded", "gate")
|
|
352
|
+
else:
|
|
353
|
+
with self.metrics.latency.stage(m, "routing"):
|
|
354
|
+
decision = router.decide(message, attachments, route, history_msgs)
|
|
355
|
+
if decision.route == "local":
|
|
356
|
+
with self.metrics.latency.stage(m, "local_answer"), self.scheduler.task(TaskClass.AGENT if spec is not None else TaskClass.CHAT, preemptible=True):
|
|
357
|
+
ans = router.answer(message, history_msgs, attachments if decision.source == "override" else None)
|
|
358
|
+
m.latency_local += time.perf_counter() - t0
|
|
359
|
+
if ans.text is not None:
|
|
360
|
+
sess.turns.append(Turn(message, ans.text, local=True))
|
|
361
|
+
self._remember(sid, message, ephemeral)
|
|
362
|
+
m.local_tokens = ans.prompt_tokens + ans.completion_tokens
|
|
363
|
+
return TurnResult(reply=ans.text, handled_by="local", route_reason=decision.reason,
|
|
364
|
+
local_tokens={"input": ans.prompt_tokens, "output": ans.completion_tokens})
|
|
365
|
+
if decision.source == "override":
|
|
366
|
+
raise UserInputError(f"local model could not answer ({ans.reason}); switch routing to auto or API")
|
|
367
|
+
decision = RouteDecision("api", ans.reason, "escalated")
|
|
368
|
+
m.escalated = True
|
|
369
|
+
else:
|
|
370
|
+
m.latency_local += time.perf_counter() - t0
|
|
371
|
+
|
|
372
|
+
# 3. API path
|
|
373
|
+
api = self.api_core(provider, model, api_key)
|
|
374
|
+
counter = self.metrics.tokens.counter_for(s.token_counter, api)
|
|
375
|
+
t0 = time.perf_counter()
|
|
376
|
+
with self.metrics.latency.stage(m, "input_saver"):
|
|
377
|
+
result = self.reduce(counter, message, mode, methods, pasted)
|
|
378
|
+
m.latency_local += time.perf_counter() - t0
|
|
379
|
+
self.metrics.tokens.credit(m, "input_savers", result.tokens_saved)
|
|
380
|
+
|
|
381
|
+
# attachments: project summaries and small notes go inline; documents are indexed
|
|
382
|
+
file_blocks, stubs, new_docs = [], [], set()
|
|
383
|
+
for a in attachments:
|
|
384
|
+
note = f" | {a['note']}" if a.get("note") else ""
|
|
385
|
+
if a.get("kind") in (_PROJECT_KIND, "media") or sess.docs is None:
|
|
386
|
+
file_blocks.append(f"[file: {a['name']}{note}]\n{a['text']}")
|
|
387
|
+
stubs.append(f"[file: {a['name']}{note}]")
|
|
388
|
+
continue
|
|
389
|
+
sess.docs.add(a["name"], a["text"])
|
|
390
|
+
new_docs.add(a["name"])
|
|
391
|
+
file_blocks.append(f"[file: {a['name']}{note}]\n{sess.docs.upload_view(a['name'], a['text'], bool(s.tools.enabled))}")
|
|
392
|
+
stubs.append(sess.docs.stub(a["name"]))
|
|
393
|
+
file_block = "".join(f"\n\n{b}" for b in file_blocks)
|
|
394
|
+
history_user = result.reduced + "".join(f"\n{x}" for x in stubs)
|
|
395
|
+
|
|
396
|
+
# everything besides the message itself: history (compressed by age), memory, document and code excerpts, under one budget
|
|
397
|
+
with self.metrics.latency.stage(m, "context"):
|
|
398
|
+
bundle = self.context.build(sess, sid, (query or "").strip() or message, new_docs, project, counter, ephemeral, m)
|
|
399
|
+
turns, paged, recall = bundle.turns, bundle.paged, bundle.recall
|
|
400
|
+
doc_info, graph_info = bundle.doc_info, bundle.graph_info
|
|
401
|
+
extras = bundle.texts
|
|
402
|
+
|
|
403
|
+
request_content = result.reduced + file_block + "".join(f"\n\n{x}" for x in extras)
|
|
404
|
+
system = self.system_prompt(
|
|
405
|
+
local_turns=any(t.local for t in turns), graph_project=project,
|
|
406
|
+
documents=bool(sess.docs and sess.docs.docs), paged=bool(paged and paged.dropped_turns),
|
|
407
|
+
compressed=bundle.uses_compression,
|
|
408
|
+
tools=sess.toolbox.prompt(message), agent_role=spec.role if spec else "")
|
|
409
|
+
messages: list[dict] = [{"role": "system", "content": system}]
|
|
410
|
+
for t in turns:
|
|
411
|
+
messages += [{"role": "user", "content": t.user},
|
|
412
|
+
{"role": "assistant", "content": (f"{LOCAL_TAG} " if t.local else "") + t.assistant}]
|
|
413
|
+
messages.append({"role": "user", "content": request_content})
|
|
414
|
+
|
|
415
|
+
sess.toolbox.price_fn = lambda a=api: getattr(a, "price_per_token", lambda: None)()
|
|
416
|
+
key = api_key or getattr(getattr(api, "config", None), "api_key", None) or ""
|
|
417
|
+
line = (f"{provider}:{hashlib.sha1(key.encode()).hexdigest()[:8]}", self.scheduler.interval_for(provider, model)) # noqa: S324
|
|
418
|
+
reply, in_tok, out_tok, tool_log = self._call_with_tools(sess, api, messages, m, message, spec, line)
|
|
419
|
+
sess.turns.append(Turn(history_user, reply, files=tuple(sess.toolbox.saved_files)))
|
|
420
|
+
self._remember(sid, message, ephemeral)
|
|
421
|
+
m.input_tokens, m.output_tokens = in_tok, out_tok
|
|
422
|
+
return TurnResult(
|
|
423
|
+
reply=reply, handled_by="api", route_reason=decision.reason, sent=request_content,
|
|
424
|
+
input_tokens=in_tok, output_tokens=out_tok, saver=result, graph=graph_info, documents=doc_info,
|
|
425
|
+
memory={"notes": recall.hits, "tokens": recall.tokens} if recall else None,
|
|
426
|
+
context=self.context.describe(sess, bundle, m),
|
|
427
|
+
tools=tool_log,
|
|
428
|
+
fallback=({"provider": self.settings.fallback.provider, "model": self.settings.fallback.model,
|
|
429
|
+
"reason": m.fallback_reason} if m.fallback else None),
|
|
430
|
+
cost_usd=m.cost_usd, files=list(sess.toolbox.saved_files))
|
|
431
|
+
|
|
432
|
+
# -- one API call through its line, with the optional fallback -----------------------------------------------------
|
|
433
|
+
def _api_once(self, sess: Session, api, messages: list[dict], spec: Optional[AgentSpec], line: tuple[str, float]):
|
|
434
|
+
def api_call(msgs: list[dict] = messages) -> LLMResponse:
|
|
435
|
+
return sess.sdk.chat_response(msgs, core=api, max_tokens=spec.max_tokens if spec else 2048)
|
|
436
|
+
|
|
437
|
+
ticket = self.scheduler.api.submit(line[0], api_call, line[1], label="api call")
|
|
438
|
+
return ticket.result(), ticket # waits its turn in the line (strictly first come, first served)
|
|
439
|
+
|
|
440
|
+
@staticmethod
|
|
441
|
+
def _provider_error(api, e: BaseException) -> ProviderError:
|
|
442
|
+
text = redact(str(e), getattr(getattr(api, "config", None), "api_key", None))
|
|
443
|
+
if any(k in text.lower() for k in ("429", "resource_exhausted", "quota", "rate limit")):
|
|
444
|
+
return ProviderError("rate limit or quota reached for this API key (free tiers allow only a few "
|
|
445
|
+
"requests per minute and per day). Wait a minute and try again.")
|
|
446
|
+
return ProviderError(f"{type(e).__name__}: {text}")
|
|
447
|
+
|
|
448
|
+
def _fallback_for(self, e: BaseException, spec: Optional[AgentSpec]):
|
|
449
|
+
"""(core, line) for the fallback model if this failure is the kind it is for, else None."""
|
|
450
|
+
fb = self.settings.fallback
|
|
451
|
+
if not (fb.enabled and fb.model) or (spec is not None and not fb.for_agents) or not is_fallback_error(e):
|
|
452
|
+
return None
|
|
453
|
+
try:
|
|
454
|
+
core = self.api_core(fb.provider, fb.model, None)
|
|
455
|
+
except (UserInputError, PalError) as why:
|
|
456
|
+
log.warning("fallback to %s/%s is not available: %s", fb.provider, fb.model, why)
|
|
457
|
+
return None
|
|
458
|
+
key = getattr(getattr(core, "config", None), "api_key", None) or ""
|
|
459
|
+
return core, (f"{fb.provider}:{hashlib.sha1(key.encode()).hexdigest()[:8]}", # noqa: S324
|
|
460
|
+
self.scheduler.interval_for(fb.provider, fb.model))
|
|
461
|
+
|
|
462
|
+
def _call_with_tools(self, sess: Session, api, messages: list[dict], m: TurnMetric, message: str,
|
|
463
|
+
spec: Optional[AgentSpec] = None, line: tuple[str, float] = ("api", 0.0)):
|
|
464
|
+
"""API call, then up to tools.max_rounds read-only tool calls. Returns (reply, tokens in, out, log)."""
|
|
465
|
+
cfg = self.settings.tools
|
|
466
|
+
in_tok = out_tok = 0
|
|
467
|
+
log_: list[dict] = []
|
|
468
|
+
rounds = cfg.max_rounds if (cfg.enabled and sess.toolbox.available(message)) else 0
|
|
469
|
+
for i in range(rounds + 1):
|
|
470
|
+
try:
|
|
471
|
+
resp, ticket = self._api_once(sess, api, messages, spec, line)
|
|
472
|
+
except UserInputError:
|
|
473
|
+
raise
|
|
474
|
+
except Exception as e:
|
|
475
|
+
alt = None if m.fallback else self._fallback_for(e, spec)
|
|
476
|
+
if alt is None:
|
|
477
|
+
raise self._provider_error(api, e) from e
|
|
478
|
+
fb = self.settings.fallback
|
|
479
|
+
log.warning("answering with the fallback model %s/%s after %s", fb.provider, fb.model, type(e).__name__)
|
|
480
|
+
api, line = alt
|
|
481
|
+
m.fallback, m.fallback_reason = f"{fb.provider}/{fb.model}", type(e).__name__
|
|
482
|
+
try:
|
|
483
|
+
resp, ticket = self._api_once(sess, api, messages, spec, line)
|
|
484
|
+
except UserInputError:
|
|
485
|
+
raise
|
|
486
|
+
except Exception as e2:
|
|
487
|
+
raise self._provider_error(api, e) from e2 # report the original failure, not the fallback's
|
|
488
|
+
m.latency_api += ticket.run_seconds
|
|
489
|
+
m.cost_usd += float((resp.meta or {}).get("cost_usd") or 0.0)
|
|
490
|
+
stats = current_stats()
|
|
491
|
+
if stats is not None:
|
|
492
|
+
stats.add_api(ticket)
|
|
493
|
+
m.api_calls += 1
|
|
494
|
+
in_tok += resp.prompt_tokens or 0
|
|
495
|
+
out_tok += resp.completion_tokens or 0
|
|
496
|
+
call = sess.toolbox.parse(resp.text or "") if i < rounds else None
|
|
497
|
+
if call is None:
|
|
498
|
+
price = getattr(api, "price_per_token", lambda: None)()
|
|
499
|
+
if price: # dollars the savings were worth at this model's input price
|
|
500
|
+
m.saved_cost_usd = round(m.saved_total * price[0], 6)
|
|
501
|
+
return strip_tool_tags(resp.text or ""), in_tok, out_tok, log_
|
|
502
|
+
outcome = sess.toolbox.run(call)
|
|
503
|
+
m.tool_calls += 1
|
|
504
|
+
m.latency_tools += outcome.seconds
|
|
505
|
+
log_.append({"name": outcome.name, "ok": outcome.ok, "seconds": round(outcome.seconds, 3)})
|
|
506
|
+
messages = messages + [
|
|
507
|
+
{"role": "assistant", "content": resp.text},
|
|
508
|
+
{"role": "user", "content": f'<tool_result name="{outcome.name}">\n{outcome.text}\n</tool_result>'}]
|
|
509
|
+
return "", in_tok, out_tok, log_ # unreachable: the last round never parses a call
|
|
510
|
+
|
|
511
|
+
def local_status(self) -> dict:
|
|
512
|
+
"""Is the local LLM reachable and does it have the model? {"ok": True|False|None, "detail": str}"""
|
|
513
|
+
local = self.settings.local_llm
|
|
514
|
+
if self.local_off:
|
|
515
|
+
return {"ok": None, "detail": "off: no GPU on this computer, so Pal uses API models only"}
|
|
516
|
+
if local.get("backend") != "ollama":
|
|
517
|
+
return {"ok": None, "detail": f"{local.get('backend')} (not checked)"}
|
|
518
|
+
import requests
|
|
519
|
+
host = (local.get("host") or "http://localhost:11434").rstrip("/")
|
|
520
|
+
try:
|
|
521
|
+
tags = requests.get(host + "/api/tags", timeout=3).json()
|
|
522
|
+
names = [x["name"] for x in tags.get("models", [])]
|
|
523
|
+
have = any(n == local["model"] or n.startswith(local["model"] + ":") for n in names)
|
|
524
|
+
return {"ok": have, "detail": f"{local['model']} " + (
|
|
525
|
+
"ready" if have else f"not pulled (ollama pull {local['model']})")}
|
|
526
|
+
except Exception as e: # noqa: BLE001 (any failure means "not reachable")
|
|
527
|
+
return {"ok": False, "detail": f"Ollama unreachable: {e.__class__.__name__}"}
|
|
528
|
+
|
|
529
|
+
# -- housekeeping ----------------------------------------------------------------------------------------
|
|
530
|
+
def reset(self, session_id: str) -> None:
|
|
531
|
+
self.sessions.remove(session_id)
|
|
532
|
+
try:
|
|
533
|
+
sid = validate_session_id(session_id)
|
|
534
|
+
self.graph.reset(sid) # also covers a project uploaded before any turn
|
|
535
|
+
self.os_kernel.drop_workspace(f"chat-{safe_id(sid)}") # and an image or audio file uploaded before any turn
|
|
536
|
+
except UserInputError:
|
|
537
|
+
pass
|
|
538
|
+
|
|
539
|
+
def clear_memory(self) -> int:
|
|
540
|
+
return self.notes.clear()
|
|
541
|
+
|
|
542
|
+
def memory_count(self) -> int:
|
|
543
|
+
return self.notes.count_notes()
|
|
544
|
+
|
|
545
|
+
def warm_up_local(self):
|
|
546
|
+
"""A background (OTHER class) job: load the local model so the next real job starts fast. Returns its ticket."""
|
|
547
|
+
if self.local_off:
|
|
548
|
+
return None
|
|
549
|
+
core = self.local_core()
|
|
550
|
+
inner = getattr(core, "core", core)
|
|
551
|
+
def ping() -> LLMResponse:
|
|
552
|
+
return inner.chat(LLMSyscall(messages=[{"role": "user", "content": "hi"}], max_tokens=1))
|
|
553
|
+
|
|
554
|
+
if self.scheduler.local is None:
|
|
555
|
+
return ping()
|
|
556
|
+
return self.scheduler.local.submit(ping, TaskClass.OTHER, "warm up the local model")
|
|
557
|
+
|
|
558
|
+
def close(self) -> None:
|
|
559
|
+
self.sessions.close_all()
|
|
560
|
+
self.scheduler.close()
|
|
561
|
+
|
|
562
|
+
# -- experiment ---------------------------------------------------------------------------------------------
|
|
563
|
+
def experiment(self, text: str, methods: Optional[list], provider: str = "", model: str = "",
|
|
564
|
+
api_key: Optional[str] = None, call_api: bool = False, mode: Optional[str] = None,
|
|
565
|
+
pasted: Optional[list] = None) -> dict:
|
|
566
|
+
api = None
|
|
567
|
+
if provider and model:
|
|
568
|
+
try:
|
|
569
|
+
api = self.api_core(provider, model, api_key)
|
|
570
|
+
except UserInputError:
|
|
571
|
+
if call_api:
|
|
572
|
+
raise
|
|
573
|
+
if call_api and api is None:
|
|
574
|
+
raise UserInputError("choose a provider and model to compare API replies")
|
|
575
|
+
counter = make_counter(self.settings.token_counter, api)
|
|
576
|
+
result = self.reduce(counter, text, mode, methods, pasted or [])
|
|
577
|
+
exact = api is not None and self.settings.token_counter == "provider" # noqa: S105
|
|
578
|
+
label = f"{provider} token counter" if exact else "heuristic (chars/4)"
|
|
579
|
+
out = {"saver": result, "counter": label, "api": None}
|
|
580
|
+
if call_api and api is not None:
|
|
581
|
+
raw = api.chat(LLMSyscall(messages=[
|
|
582
|
+
{"role": "system", "content": self.system_prompt(with_output_config=False)},
|
|
583
|
+
{"role": "user", "content": text}], max_tokens=2048))
|
|
584
|
+
opt = api.chat(LLMSyscall(messages=[
|
|
585
|
+
{"role": "system", "content": self.system_prompt(with_output_config=True)},
|
|
586
|
+
{"role": "user", "content": result.reduced}], max_tokens=2048))
|
|
587
|
+
out["api"] = {
|
|
588
|
+
"raw": {"input_tokens": raw.prompt_tokens, "output_tokens": raw.completion_tokens, "reply": raw.text},
|
|
589
|
+
"optimized": {"input_tokens": opt.prompt_tokens, "output_tokens": opt.completion_tokens, "reply": opt.text},
|
|
590
|
+
}
|
|
591
|
+
return out
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Third-party service tools, the AIOS paper's Table 5 tools beyond the ones Pal already had (Wikipedia, arXiv)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
from pal.chat.services import data, media, web
|
|
7
|
+
from pal.chat.services.base import ServiceCtx, ServiceSpec
|
|
8
|
+
from pal.errors import ConfigError
|
|
9
|
+
|
|
10
|
+
ALL_SERVICES: tuple[ServiceSpec, ...] = (*web.SERVICES, *data.SERVICES, *media.SERVICES)
|
|
11
|
+
SERVICE_NAMES: tuple[str, ...] = tuple(s.name for s in ALL_SERVICES)
|
|
12
|
+
BY_NAME = {s.name: s for s in ALL_SERVICES}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def selected(names: Iterable[str]) -> list[ServiceSpec]:
|
|
16
|
+
"""The services a `tools.services` list switches on. "all" means every one. An unknown name is an error, not ignored."""
|
|
17
|
+
wanted = [str(n).strip() for n in names]
|
|
18
|
+
if "all" in wanted:
|
|
19
|
+
return list(ALL_SERVICES)
|
|
20
|
+
unknown = [n for n in wanted if n not in BY_NAME]
|
|
21
|
+
if unknown:
|
|
22
|
+
raise ConfigError(f"tools.services: unknown service(s) {unknown}; choose from {', '.join(SERVICE_NAMES)}, or all")
|
|
23
|
+
return [BY_NAME[n] for n in dict.fromkeys(wanted)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["ALL_SERVICES", "BY_NAME", "SERVICE_NAMES", "ServiceCtx", "ServiceSpec", "selected"]
|