harnessed 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.
- harnessed/__init__.py +58 -0
- harnessed/__init__.pyi +68 -0
- harnessed/agent.py +132 -0
- harnessed/core/__init__.py +0 -0
- harnessed/core/approvals.py +291 -0
- harnessed/core/attachments.py +86 -0
- harnessed/core/budget.py +49 -0
- harnessed/core/buffering.py +49 -0
- harnessed/core/compaction.py +417 -0
- harnessed/core/errors.py +105 -0
- harnessed/core/event_kinds.py +50 -0
- harnessed/core/event_registry.py +114 -0
- harnessed/core/hooks.py +112 -0
- harnessed/core/messages.py +368 -0
- harnessed/core/model.py +248 -0
- harnessed/core/outputs.py +170 -0
- harnessed/core/plugin.py +118 -0
- harnessed/core/prompt/__init__.py +0 -0
- harnessed/core/prompt/context.py +84 -0
- harnessed/core/prompt/instructions.py +19 -0
- harnessed/core/prompt/system_prompt.py +42 -0
- harnessed/core/prompt/values.py +131 -0
- harnessed/core/runtime/__init__.py +0 -0
- harnessed/core/runtime/agent_loop.py +71 -0
- harnessed/core/runtime/approvals.py +231 -0
- harnessed/core/runtime/control.py +278 -0
- harnessed/core/runtime/executor.py +209 -0
- harnessed/core/runtime/handles.py +47 -0
- harnessed/core/runtime/inbox.py +132 -0
- harnessed/core/runtime/opener.py +77 -0
- harnessed/core/runtime/options.py +71 -0
- harnessed/core/runtime/output.py +124 -0
- harnessed/core/runtime/respond.py +69 -0
- harnessed/core/runtime/result.py +168 -0
- harnessed/core/runtime/services.py +38 -0
- harnessed/core/runtime/session.py +583 -0
- harnessed/core/runtime/stepper.py +162 -0
- harnessed/core/runtime/streaming.py +174 -0
- harnessed/core/runtime/turns.py +123 -0
- harnessed/core/session/__init__.py +0 -0
- harnessed/core/session/artifacts.py +81 -0
- harnessed/core/session/codec.py +202 -0
- harnessed/core/session/control.py +35 -0
- harnessed/core/session/events.py +731 -0
- harnessed/core/session/files.py +86 -0
- harnessed/core/session/history.py +391 -0
- harnessed/core/session/inbox.py +59 -0
- harnessed/core/session/repository.py +45 -0
- harnessed/core/session/session.py +537 -0
- harnessed/core/session/store.py +138 -0
- harnessed/core/session/turns.py +220 -0
- harnessed/core/stream.py +161 -0
- harnessed/core/tokens.py +78 -0
- harnessed/core/tools/__init__.py +11 -0
- harnessed/core/tools/arguments.py +98 -0
- harnessed/core/tools/context.py +61 -0
- harnessed/core/tools/decorator.py +53 -0
- harnessed/core/tools/output_limit.py +46 -0
- harnessed/core/tools/policy.py +18 -0
- harnessed/core/tools/references.py +68 -0
- harnessed/core/tools/registry.py +390 -0
- harnessed/core/tools/scheduling.py +93 -0
- harnessed/core/tracing.py +33 -0
- harnessed/errors.py +45 -0
- harnessed/events/__init__.py +53 -0
- harnessed/events/approval.py +7 -0
- harnessed/events/compaction.py +5 -0
- harnessed/events/context.py +6 -0
- harnessed/events/inbox.py +13 -0
- harnessed/events/message.py +11 -0
- harnessed/events/model.py +11 -0
- harnessed/events/output.py +7 -0
- harnessed/events/reasoning.py +5 -0
- harnessed/events/rule.py +7 -0
- harnessed/events/session.py +6 -0
- harnessed/events/spill.py +5 -0
- harnessed/events/step.py +5 -0
- harnessed/events/text.py +5 -0
- harnessed/events/tool.py +29 -0
- harnessed/events/turn.py +6 -0
- harnessed/hooks.py +29 -0
- harnessed/kernel/__init__.py +55 -0
- harnessed/kernel/context.py +344 -0
- harnessed/kernel/declarations.py +208 -0
- harnessed/kernel/di.py +121 -0
- harnessed/kernel/effects.py +45 -0
- harnessed/kernel/events.py +206 -0
- harnessed/kernel/hooks.py +97 -0
- harnessed/kernel/keys.py +20 -0
- harnessed/kernel/plugin.py +343 -0
- harnessed/kernel/spec.py +21 -0
- harnessed/kernel/state.py +40 -0
- harnessed/models/__init__.py +58 -0
- harnessed/models/pydantic_ai.py +532 -0
- harnessed/plugins/__init__.py +56 -0
- harnessed/plugins/compaction.py +114 -0
- harnessed/plugins/filesystem.py +283 -0
- harnessed/plugins/observations.py +31 -0
- harnessed/plugins/rules.py +210 -0
- harnessed/plugins/sessions.py +449 -0
- harnessed/plugins/shell/__init__.py +276 -0
- harnessed/plugins/shell/windows.py +132 -0
- harnessed/plugins/skills.py +408 -0
- harnessed/plugins/spill.py +233 -0
- harnessed/py.typed +0 -0
- harnessed/testing/__init__.py +17 -0
- harnessed/testing/assertions.py +20 -0
- harnessed/testing/compaction.py +30 -0
- harnessed/testing/fakes.py +184 -0
- harnessed/testing/models.py +173 -0
- harnessed/testing/pytest_plugin.py +26 -0
- harnessed-0.1.0.dist-info/METADATA +123 -0
- harnessed-0.1.0.dist-info/RECORD +116 -0
- harnessed-0.1.0.dist-info/WHEEL +5 -0
- harnessed-0.1.0.dist-info/licenses/LICENSE +21 -0
- harnessed-0.1.0.dist-info/top_level.txt +1 -0
harnessed/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from importlib import import_module
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
logger.addHandler(logging.NullHandler())
|
|
7
|
+
|
|
8
|
+
# Keep the standalone kernel independent of Pydantic and optional provider SDKs.
|
|
9
|
+
_EXPORTS = {
|
|
10
|
+
"Agent": ("harnessed.agent", "Agent"),
|
|
11
|
+
"ApprovalAnswer": ("harnessed.core.messages", "ApprovalAnswer"),
|
|
12
|
+
"ApprovalRequest": ("harnessed.core.session.events", "ApprovalRequested"),
|
|
13
|
+
"Context": ("harnessed.kernel", "Context"),
|
|
14
|
+
"File": ("harnessed.core.attachments", "File"),
|
|
15
|
+
"Image": ("harnessed.core.attachments", "Image"),
|
|
16
|
+
"ImageDetail": ("harnessed.core.messages", "ImageDetail"),
|
|
17
|
+
"Inject": ("harnessed.kernel", "Inject"),
|
|
18
|
+
"Observation": ("harnessed.core.messages", "Observation"),
|
|
19
|
+
"OutputContract": ("harnessed.core.outputs", "OutputContract"),
|
|
20
|
+
"Plugin": ("harnessed.core.plugin", "Plugin"),
|
|
21
|
+
"PluginConfig": ("harnessed.core.plugin", "PluginConfig"),
|
|
22
|
+
"PluginState": ("harnessed.kernel", "PluginState"),
|
|
23
|
+
"PluginConflict": ("harnessed.core.errors", "PluginConflict"),
|
|
24
|
+
"PolicyDecision": ("harnessed.core.messages", "PolicyDecision"),
|
|
25
|
+
"Role": ("harnessed.core.messages", "Role"),
|
|
26
|
+
"RunHandle": ("harnessed.core.runtime.handles", "RunHandle"),
|
|
27
|
+
"RunResult": ("harnessed.core.runtime.result", "RunResult"),
|
|
28
|
+
"Section": ("harnessed.core.prompt.values", "Section"),
|
|
29
|
+
"Session": ("harnessed.core.runtime.session", "Session"),
|
|
30
|
+
"SessionClosed": ("harnessed.core.errors", "SessionClosed"),
|
|
31
|
+
"SessionStatus": ("harnessed.core.runtime.control", "SessionStatus"),
|
|
32
|
+
"StopReason": ("harnessed.core.model", "StopReason"),
|
|
33
|
+
"StreamOverflow": ("harnessed.core.errors", "StreamOverflow"),
|
|
34
|
+
"ToolCall": ("harnessed.core.messages", "ToolCall"),
|
|
35
|
+
"ToolContext": ("harnessed.core.tools.context", "ToolContext"),
|
|
36
|
+
"ToolError": ("harnessed.core.tools.registry", "ToolError"),
|
|
37
|
+
"ToolResult": ("harnessed.core.messages", "ToolResult"),
|
|
38
|
+
"ToolStatus": ("harnessed.core.messages", "ToolStatus"),
|
|
39
|
+
"hook": ("harnessed.kernel", "hook"),
|
|
40
|
+
"plugin": ("harnessed.kernel", "plugin"),
|
|
41
|
+
"tool": ("harnessed.core.tools.decorator", "tool"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def __getattr__(name: str) -> Any:
|
|
46
|
+
if name not in _EXPORTS:
|
|
47
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
48
|
+
module, attribute = _EXPORTS[name]
|
|
49
|
+
value = getattr(import_module(module), attribute)
|
|
50
|
+
globals()[name] = value
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def __dir__() -> list[str]:
|
|
55
|
+
return sorted(set(globals()) | set(_EXPORTS))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = sorted(_EXPORTS)
|
harnessed/__init__.pyi
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from harnessed.agent import Agent as Agent
|
|
2
|
+
from harnessed.core.attachments import File as File
|
|
3
|
+
from harnessed.core.attachments import Image as Image
|
|
4
|
+
from harnessed.core.errors import PluginConflict as PluginConflict
|
|
5
|
+
from harnessed.core.errors import SessionClosed as SessionClosed
|
|
6
|
+
from harnessed.core.errors import StreamOverflow as StreamOverflow
|
|
7
|
+
from harnessed.core.messages import ApprovalAnswer as ApprovalAnswer
|
|
8
|
+
from harnessed.core.messages import ImageDetail as ImageDetail
|
|
9
|
+
from harnessed.core.messages import Observation as Observation
|
|
10
|
+
from harnessed.core.messages import PolicyDecision as PolicyDecision
|
|
11
|
+
from harnessed.core.messages import Role as Role
|
|
12
|
+
from harnessed.core.messages import ToolCall as ToolCall
|
|
13
|
+
from harnessed.core.messages import ToolResult as ToolResult
|
|
14
|
+
from harnessed.core.messages import ToolStatus as ToolStatus
|
|
15
|
+
from harnessed.core.model import StopReason as StopReason
|
|
16
|
+
from harnessed.core.outputs import OutputContract as OutputContract
|
|
17
|
+
from harnessed.core.plugin import Plugin as Plugin
|
|
18
|
+
from harnessed.core.plugin import PluginConfig as PluginConfig
|
|
19
|
+
from harnessed.core.prompt.values import Section as Section
|
|
20
|
+
from harnessed.core.runtime.control import SessionStatus as SessionStatus
|
|
21
|
+
from harnessed.core.runtime.handles import RunHandle as RunHandle
|
|
22
|
+
from harnessed.core.runtime.result import RunResult as RunResult
|
|
23
|
+
from harnessed.core.runtime.session import Session as Session
|
|
24
|
+
from harnessed.core.session.events import ApprovalRequested as ApprovalRequest
|
|
25
|
+
from harnessed.core.tools.context import ToolContext as ToolContext
|
|
26
|
+
from harnessed.core.tools.decorator import tool as tool
|
|
27
|
+
from harnessed.core.tools.registry import ToolError as ToolError
|
|
28
|
+
from harnessed.kernel import Context as Context
|
|
29
|
+
from harnessed.kernel import Inject as Inject
|
|
30
|
+
from harnessed.kernel import PluginState as PluginState
|
|
31
|
+
from harnessed.kernel import hook as hook
|
|
32
|
+
from harnessed.kernel import plugin as plugin
|
|
33
|
+
|
|
34
|
+
from . import events as events
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Agent",
|
|
38
|
+
"ApprovalAnswer",
|
|
39
|
+
"ApprovalRequest",
|
|
40
|
+
"Context",
|
|
41
|
+
"File",
|
|
42
|
+
"Image",
|
|
43
|
+
"ImageDetail",
|
|
44
|
+
"Inject",
|
|
45
|
+
"Observation",
|
|
46
|
+
"OutputContract",
|
|
47
|
+
"Plugin",
|
|
48
|
+
"PluginConfig",
|
|
49
|
+
"PluginConflict",
|
|
50
|
+
"PolicyDecision",
|
|
51
|
+
"Role",
|
|
52
|
+
"RunHandle",
|
|
53
|
+
"RunResult",
|
|
54
|
+
"Section",
|
|
55
|
+
"Session",
|
|
56
|
+
"SessionClosed",
|
|
57
|
+
"SessionStatus",
|
|
58
|
+
"StopReason",
|
|
59
|
+
"StreamOverflow",
|
|
60
|
+
"ToolCall",
|
|
61
|
+
"ToolContext",
|
|
62
|
+
"ToolError",
|
|
63
|
+
"ToolResult",
|
|
64
|
+
"ToolStatus",
|
|
65
|
+
"hook",
|
|
66
|
+
"plugin",
|
|
67
|
+
"tool",
|
|
68
|
+
]
|
harnessed/agent.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator, Iterable
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Unpack, overload
|
|
6
|
+
|
|
7
|
+
from harnessed.core.approvals import Approvals
|
|
8
|
+
from harnessed.core.errors import PluginConflict
|
|
9
|
+
from harnessed.core.event_registry import EventRegistry
|
|
10
|
+
from harnessed.core.model import Model
|
|
11
|
+
from harnessed.core.outputs import OutputContract, OutputTypes
|
|
12
|
+
from harnessed.core.plugin import Service, plugin_contribution, plugin_events
|
|
13
|
+
from harnessed.core.prompt.instructions import Instructions
|
|
14
|
+
from harnessed.core.prompt.values import InstructionValues, PromptComposition
|
|
15
|
+
from harnessed.core.runtime.agent_loop import AgentLoop
|
|
16
|
+
from harnessed.core.runtime.opener import open_session
|
|
17
|
+
from harnessed.core.runtime.options import RunOptions
|
|
18
|
+
from harnessed.core.runtime.result import RunResult
|
|
19
|
+
from harnessed.core.runtime.services import RuntimeServices
|
|
20
|
+
from harnessed.core.runtime.session import Session
|
|
21
|
+
from harnessed.core.session.repository import SessionRepository
|
|
22
|
+
from harnessed.core.session.session import SessionLog
|
|
23
|
+
from harnessed.core.session.store import MemorySessions
|
|
24
|
+
from harnessed.core.tools.output_limit import ToolOutputLimit
|
|
25
|
+
from harnessed.core.tracing import NoopTracer
|
|
26
|
+
from harnessed.kernel import Context, PluginLike
|
|
27
|
+
from harnessed.kernel.plugin import as_plugin_spec
|
|
28
|
+
from harnessed.models.pydantic_ai import PydanticAIModel
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from harnessed.models.pydantic_ai import NativeModel
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Agent:
|
|
35
|
+
"""Resolve model, instructions and plugins into a reusable configuration.
|
|
36
|
+
|
|
37
|
+
Each opened session owns its runtime and plugin lifetimes. The configured
|
|
38
|
+
session-store plugin retains conversations and enforces a single writer.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
model: Model | str | NativeModel | None = None,
|
|
45
|
+
instructions: InstructionValues | None = None,
|
|
46
|
+
plugins: Iterable[PluginLike] = (),
|
|
47
|
+
) -> None:
|
|
48
|
+
supplied: list[PluginLike] = list(plugins)
|
|
49
|
+
if instructions is not None:
|
|
50
|
+
supplied.insert(0, Instructions(instructions))
|
|
51
|
+
if model is not None:
|
|
52
|
+
supplied.insert(0, Service(Model, model))
|
|
53
|
+
normalized: list[PluginLike] = []
|
|
54
|
+
for value in supplied:
|
|
55
|
+
if isinstance(value, Service) and value.key is Model:
|
|
56
|
+
implementation = value.value
|
|
57
|
+
if not isinstance(implementation, Model):
|
|
58
|
+
implementation = PydanticAIModel(implementation)
|
|
59
|
+
value = Service(Model, implementation)
|
|
60
|
+
normalized.append(value)
|
|
61
|
+
declared = next((value for value in normalized if isinstance(value, Instructions)), None)
|
|
62
|
+
self.prompt = PromptComposition(
|
|
63
|
+
declared.values if declared is not None else (),
|
|
64
|
+
(plugin_contribution(value) for value in normalized),
|
|
65
|
+
)
|
|
66
|
+
defaults: list[PluginLike] = [
|
|
67
|
+
AgentLoop(),
|
|
68
|
+
MemorySessions(),
|
|
69
|
+
ToolOutputLimit(),
|
|
70
|
+
Approvals(),
|
|
71
|
+
NoopTracer(),
|
|
72
|
+
Instructions(composition=self.prompt),
|
|
73
|
+
]
|
|
74
|
+
occupied = {as_plugin_spec(value).slot: index for index, value in enumerate(defaults)}
|
|
75
|
+
explicit: set[str] = set()
|
|
76
|
+
for value in normalized:
|
|
77
|
+
if isinstance(value, Instructions):
|
|
78
|
+
value = Instructions(value.values, composition=self.prompt)
|
|
79
|
+
spec = as_plugin_spec(value)
|
|
80
|
+
if spec.slot is not None:
|
|
81
|
+
if spec.slot in explicit:
|
|
82
|
+
raise PluginConflict(f"Multiple plugins in slot {spec.slot!r}")
|
|
83
|
+
explicit.add(spec.slot)
|
|
84
|
+
if spec.slot in occupied:
|
|
85
|
+
defaults[occupied[spec.slot]] = value
|
|
86
|
+
continue
|
|
87
|
+
occupied[spec.slot] = len(defaults)
|
|
88
|
+
defaults.append(value)
|
|
89
|
+
self.plugins = (RuntimeServices(), *defaults)
|
|
90
|
+
self._events = EventRegistry(
|
|
91
|
+
kind for plugin in self.plugins for kind in plugin_events(plugin)
|
|
92
|
+
)
|
|
93
|
+
self.sessions = SessionRepository(self.plugins, registry=self._events)
|
|
94
|
+
|
|
95
|
+
@asynccontextmanager
|
|
96
|
+
async def session(
|
|
97
|
+
self,
|
|
98
|
+
session_id: str | SessionLog | None = None,
|
|
99
|
+
*,
|
|
100
|
+
restore_outputs: Iterable[type[Any] | OutputContract[Any]] = (),
|
|
101
|
+
) -> AsyncGenerator[Session]:
|
|
102
|
+
"""Open or resume a conversation; omit its ID to create a new one."""
|
|
103
|
+
async with Context() as ctx:
|
|
104
|
+
ctx.provide(EventRegistry, self._events.copy())
|
|
105
|
+
# Validators are per session, so dynamic output types never leak between them.
|
|
106
|
+
outputs = OutputTypes()
|
|
107
|
+
for output in restore_outputs:
|
|
108
|
+
outputs.register(output)
|
|
109
|
+
ctx.provide(OutputTypes, outputs)
|
|
110
|
+
await ctx.install(self.plugins, settle=True)
|
|
111
|
+
yield await open_session(ctx, session_id)
|
|
112
|
+
|
|
113
|
+
@overload
|
|
114
|
+
async def run[T](
|
|
115
|
+
self, prompt: str, *, output: type[T] | OutputContract[T], **options: Unpack[RunOptions]
|
|
116
|
+
) -> RunResult[T]: ...
|
|
117
|
+
|
|
118
|
+
@overload
|
|
119
|
+
async def run(
|
|
120
|
+
self, prompt: str, *, output: None = None, **options: Unpack[RunOptions]
|
|
121
|
+
) -> RunResult[None]: ...
|
|
122
|
+
|
|
123
|
+
async def run(
|
|
124
|
+
self,
|
|
125
|
+
prompt: str,
|
|
126
|
+
*,
|
|
127
|
+
output: type[Any] | OutputContract[Any] | None = None,
|
|
128
|
+
**options: Unpack[RunOptions],
|
|
129
|
+
) -> RunResult[Any]:
|
|
130
|
+
"""Run one task in a new session."""
|
|
131
|
+
async with self.session() as session:
|
|
132
|
+
return await session.run(prompt, output=output, **options)
|
|
File without changes
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
from typing import Annotated, Protocol, Self, runtime_checkable
|
|
5
|
+
|
|
6
|
+
from pydantic import ConfigDict, Field, TypeAdapter
|
|
7
|
+
|
|
8
|
+
from harnessed.core.hooks import ToolPolicy
|
|
9
|
+
from harnessed.core.messages import ApprovalAnswer, PolicyDecision
|
|
10
|
+
from harnessed.core.plugin import Plugin, PluginConfig
|
|
11
|
+
from harnessed.core.session.events import (
|
|
12
|
+
ApprovalRequested,
|
|
13
|
+
ApprovalResolved,
|
|
14
|
+
ApprovalRevoked,
|
|
15
|
+
ApprovalStatus,
|
|
16
|
+
SessionEvent,
|
|
17
|
+
)
|
|
18
|
+
from harnessed.core.tools.references import ToolReference, ToolSelector, tool_selector
|
|
19
|
+
from harnessed.core.tools.registry import ToolRegistry
|
|
20
|
+
from harnessed.kernel import Context, Inject, Next, Priority, hook
|
|
21
|
+
|
|
22
|
+
from .messages import ToolCall
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@runtime_checkable
|
|
26
|
+
class Approver(Protocol):
|
|
27
|
+
async def ask(self, request: ApprovalRequested) -> ApprovalAnswer | None: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
type ApprovalCallback = Callable[
|
|
31
|
+
[ApprovalRequested], ApprovalAnswer | bool | None | Awaitable[ApprovalAnswer | bool | None]
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CallbackApprover:
|
|
36
|
+
"""Adapt a request callback to the narrow Approver service."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, callback: ApprovalCallback) -> None:
|
|
39
|
+
self.callback = callback
|
|
40
|
+
|
|
41
|
+
async def ask(self, request: ApprovalRequested) -> ApprovalAnswer | None:
|
|
42
|
+
value = self.callback(request)
|
|
43
|
+
value = await value if inspect.isawaitable(value) else value
|
|
44
|
+
if value is None or isinstance(value, ApprovalAnswer):
|
|
45
|
+
return value
|
|
46
|
+
if type(value) is bool:
|
|
47
|
+
return ApprovalAnswer.ALLOW if value else ApprovalAnswer.DENY
|
|
48
|
+
raise TypeError("Approvers must return ApprovalAnswer or bool")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ApprovalGateConfig(PluginConfig):
|
|
52
|
+
risky: Annotated[
|
|
53
|
+
tuple[Annotated[str, Field(min_length=1)] | Callable | ToolReference, ...],
|
|
54
|
+
Field(strict=False),
|
|
55
|
+
]
|
|
56
|
+
ask: ApprovalCallback | None = None
|
|
57
|
+
scope: Annotated[str, Field(min_length=1)] = "approval-gate"
|
|
58
|
+
priority: int = Priority.GUARD
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ApprovalGate(Plugin):
|
|
62
|
+
"""Keep a scoped guard active even when its optional approver is unavailable."""
|
|
63
|
+
|
|
64
|
+
Config = ApprovalGateConfig
|
|
65
|
+
|
|
66
|
+
config: ApprovalGateConfig
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
risky: Iterable[ToolSelector],
|
|
72
|
+
ask: ApprovalCallback | None = None,
|
|
73
|
+
scope: str = "approval-gate",
|
|
74
|
+
priority: int = Priority.GUARD,
|
|
75
|
+
) -> None:
|
|
76
|
+
super().__init__(risky=risky, ask=ask, scope=scope, priority=priority)
|
|
77
|
+
self.risky = tuple(tool_selector(item) for item in self.config.risky)
|
|
78
|
+
self.ask = self.config.ask
|
|
79
|
+
|
|
80
|
+
async def setup(self, ctx: Context, tools: Inject[ToolRegistry]) -> None:
|
|
81
|
+
self.risky = tuple(tools.reference(item, owner=ctx) for item in self.risky)
|
|
82
|
+
ctx.on(ToolPolicy, self._gate, priority=self.config.priority)
|
|
83
|
+
|
|
84
|
+
async def _gate(
|
|
85
|
+
self, event: ToolPolicy, next_: Next[PolicyDecision], tools: Inject[ToolRegistry]
|
|
86
|
+
) -> PolicyDecision:
|
|
87
|
+
if not any(tools.matches(item, event.call.name) for item in self.risky):
|
|
88
|
+
return await next_()
|
|
89
|
+
answer = await event.session.approvals.request(
|
|
90
|
+
event.call,
|
|
91
|
+
f"{event.call.name} is marked risky",
|
|
92
|
+
scope=self.config.scope,
|
|
93
|
+
approver=CallbackApprover(self.ask) if self.ask is not None else None,
|
|
94
|
+
)
|
|
95
|
+
if answer not in (ApprovalAnswer.ALLOW, ApprovalAnswer.ALWAYS):
|
|
96
|
+
return PolicyDecision.DENY
|
|
97
|
+
return await next_()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_APPROVAL_TIMEOUT = TypeAdapter(
|
|
101
|
+
Annotated[float, Field(gt=0)],
|
|
102
|
+
config=ConfigDict(
|
|
103
|
+
title="approval_timeout", strict=True, allow_inf_nan=False, hide_input_in_errors=True
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate_approval_timeout(value: float) -> None:
|
|
109
|
+
_APPROVAL_TIMEOUT.validate_python(value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class Approval:
|
|
114
|
+
request: ApprovalRequested
|
|
115
|
+
resolution: ApprovalResolved | None = None
|
|
116
|
+
revoked: bool = False
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def request_id(self) -> str:
|
|
120
|
+
return self.request.request_id
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def pending(self) -> bool:
|
|
124
|
+
return self.resolution is None
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def decision(self) -> ApprovalAnswer | None:
|
|
128
|
+
return self.resolution.decision if self.resolution is not None else None
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def is_grant(self) -> bool:
|
|
132
|
+
return (
|
|
133
|
+
not self.revoked
|
|
134
|
+
and self.decision is ApprovalAnswer.ALWAYS
|
|
135
|
+
and self.request.grant_id is None
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True, init=False)
|
|
140
|
+
class ApprovalState:
|
|
141
|
+
records: tuple[Approval, ...]
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def from_records(cls, records: Iterable[Approval]) -> Self:
|
|
145
|
+
snapshot = object.__new__(cls)
|
|
146
|
+
object.__setattr__(snapshot, "records", tuple(records))
|
|
147
|
+
return snapshot
|
|
148
|
+
|
|
149
|
+
def __init__(self, events: Iterable[SessionEvent]) -> None:
|
|
150
|
+
records: dict[str, Approval] = {}
|
|
151
|
+
for event in events:
|
|
152
|
+
record = approval_transition(records, event)
|
|
153
|
+
if record is not None:
|
|
154
|
+
records[record.request_id] = record
|
|
155
|
+
object.__setattr__(self, "records", tuple(records.values()))
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def pending(self) -> tuple[Approval, ...]:
|
|
159
|
+
return tuple(record for record in self.records if record.pending)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def grants(self) -> tuple[Approval, ...]:
|
|
163
|
+
return tuple(record for record in self.records if record.is_grant)
|
|
164
|
+
|
|
165
|
+
def grant_for(self, tool: str, *, scope: str) -> Approval | None:
|
|
166
|
+
return next(
|
|
167
|
+
(
|
|
168
|
+
record
|
|
169
|
+
for record in reversed(self.grants)
|
|
170
|
+
if record.request.call.name == tool and record.request.scope == scope
|
|
171
|
+
),
|
|
172
|
+
None,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def approval_transition(records, event: SessionEvent) -> Approval | None:
|
|
177
|
+
if isinstance(event, ApprovalRequested):
|
|
178
|
+
if event.request_id in records:
|
|
179
|
+
raise ValueError("Duplicate approval request_id")
|
|
180
|
+
if event.grant_id is not None:
|
|
181
|
+
grant = records.get(event.grant_id)
|
|
182
|
+
if (
|
|
183
|
+
grant is None
|
|
184
|
+
or not grant.is_grant
|
|
185
|
+
or grant.request.scope != event.scope
|
|
186
|
+
or grant.request.call.name != event.call.name
|
|
187
|
+
):
|
|
188
|
+
raise ValueError(
|
|
189
|
+
"Cached approval must reference an active grant for its tool and scope"
|
|
190
|
+
)
|
|
191
|
+
return Approval(event)
|
|
192
|
+
elif isinstance(event, ApprovalResolved):
|
|
193
|
+
record = records.get(event.request_id)
|
|
194
|
+
if record is None or not record.pending:
|
|
195
|
+
raise ValueError("Approval resolution requires a pending request")
|
|
196
|
+
if record.request.grant_id is not None and event.status == ApprovalStatus.ANSWERED:
|
|
197
|
+
grant = records[record.request.grant_id]
|
|
198
|
+
if not grant.is_grant or event.decision is not ApprovalAnswer.ALWAYS:
|
|
199
|
+
raise ValueError("Cached approval grant is no longer active")
|
|
200
|
+
return replace(record, resolution=event)
|
|
201
|
+
elif isinstance(event, ApprovalRevoked):
|
|
202
|
+
record = records.get(event.request_id)
|
|
203
|
+
if record is None or not record.is_grant:
|
|
204
|
+
raise ValueError("Approval revocation requires an active ALWAYS grant")
|
|
205
|
+
return replace(record, revoked=True)
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ApprovalsConfig(PluginConfig):
|
|
210
|
+
timeout: Annotated[float, Field(gt=0)] = 300.0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class Approvals(Plugin, slot="approvals"):
|
|
214
|
+
"""Compose default decisions and explicit tool rules; required approval wins trust."""
|
|
215
|
+
|
|
216
|
+
Config = ApprovalsConfig
|
|
217
|
+
|
|
218
|
+
config: ApprovalsConfig
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
*,
|
|
223
|
+
approver: Approver | ApprovalCallback | None = None,
|
|
224
|
+
default: PolicyDecision = PolicyDecision.ASK,
|
|
225
|
+
trust: Iterable[ToolSelector] = (),
|
|
226
|
+
require: Iterable[ToolSelector] = (),
|
|
227
|
+
deny: Iterable[ToolSelector] = (),
|
|
228
|
+
policy: Callable[[ToolCall], PolicyDecision | None] | None = None,
|
|
229
|
+
timeout: float = 300.0,
|
|
230
|
+
) -> None:
|
|
231
|
+
super().__init__(timeout=timeout)
|
|
232
|
+
self.approver: Approver | None = (
|
|
233
|
+
approver
|
|
234
|
+
if approver is None or isinstance(approver, Approver)
|
|
235
|
+
else CallbackApprover(approver)
|
|
236
|
+
)
|
|
237
|
+
if not isinstance(default, PolicyDecision):
|
|
238
|
+
raise TypeError("default must be a PolicyDecision")
|
|
239
|
+
if any(isinstance(items, str) for items in (trust, require, deny)):
|
|
240
|
+
raise TypeError(
|
|
241
|
+
"Tool rules must be collections; use default=PolicyDecision.ALLOW to allow all"
|
|
242
|
+
)
|
|
243
|
+
self.default = default
|
|
244
|
+
self.trust = tuple(tool_selector(item) for item in trust)
|
|
245
|
+
self.require = tuple(tool_selector(item) for item in require)
|
|
246
|
+
self.deny = tuple(tool_selector(item) for item in deny)
|
|
247
|
+
self.policy = policy
|
|
248
|
+
|
|
249
|
+
async def setup(self, ctx: Context, tools: Inject[ToolRegistry]) -> None:
|
|
250
|
+
self.trust = tuple(tools.reference(item, owner=ctx) for item in self.trust)
|
|
251
|
+
self.require = tuple(tools.reference(item, owner=ctx) for item in self.require)
|
|
252
|
+
self.deny = tuple(tools.reference(item, owner=ctx) for item in self.deny)
|
|
253
|
+
if self.approver is not None:
|
|
254
|
+
# Tools and other guards asking mid-call reach the same human.
|
|
255
|
+
ctx.provide(Approver, self.approver)
|
|
256
|
+
|
|
257
|
+
@hook(ToolPolicy, priority=Priority.GUARD)
|
|
258
|
+
async def authorize(
|
|
259
|
+
self, event: ToolPolicy, next_: Next[PolicyDecision], tools: Inject[ToolRegistry]
|
|
260
|
+
) -> PolicyDecision:
|
|
261
|
+
session = event.session
|
|
262
|
+
if any(tools.matches(item, event.call.name) for item in self.deny):
|
|
263
|
+
return PolicyDecision.DENY
|
|
264
|
+
required = any(tools.matches(item, event.call.name) for item in self.require)
|
|
265
|
+
decision = (
|
|
266
|
+
PolicyDecision.ASK
|
|
267
|
+
if required
|
|
268
|
+
else (self.policy(event.call) if self.policy is not None else None)
|
|
269
|
+
)
|
|
270
|
+
if decision is None:
|
|
271
|
+
decision = (
|
|
272
|
+
PolicyDecision.ALLOW
|
|
273
|
+
if any(tools.matches(item, event.call.name) for item in self.trust)
|
|
274
|
+
or tools.is_readonly(event.call.name)
|
|
275
|
+
else self.default
|
|
276
|
+
)
|
|
277
|
+
if decision is not None and not isinstance(decision, PolicyDecision):
|
|
278
|
+
raise TypeError("Approval policies must return PolicyDecision or None")
|
|
279
|
+
if decision is PolicyDecision.ASK:
|
|
280
|
+
answer = await session.approvals.request(
|
|
281
|
+
event.call,
|
|
282
|
+
"Tool may have side effects",
|
|
283
|
+
approver=self.approver,
|
|
284
|
+
timeout=self.config.timeout,
|
|
285
|
+
)
|
|
286
|
+
decision = (
|
|
287
|
+
PolicyDecision.DENY if answer is ApprovalAnswer.DENY else PolicyDecision.ALLOW
|
|
288
|
+
)
|
|
289
|
+
if decision is PolicyDecision.ALLOW:
|
|
290
|
+
return await next_()
|
|
291
|
+
return PolicyDecision.DENY
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import mimetypes
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from harnessed.core.messages import (
|
|
11
|
+
ContentPart,
|
|
12
|
+
ImageDetail,
|
|
13
|
+
ImagePart,
|
|
14
|
+
Message,
|
|
15
|
+
Role,
|
|
16
|
+
TextPart,
|
|
17
|
+
User,
|
|
18
|
+
freeze,
|
|
19
|
+
system,
|
|
20
|
+
to_dict,
|
|
21
|
+
user,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class File:
|
|
27
|
+
path: str | Path
|
|
28
|
+
encoding: str = "utf-8"
|
|
29
|
+
|
|
30
|
+
async def parts(self) -> tuple[ContentPart, ...]:
|
|
31
|
+
path = Path(self.path)
|
|
32
|
+
text = await asyncio.to_thread(path.read_text, encoding=self.encoding)
|
|
33
|
+
return (TextPart(f"File: {path.name}\n{text}"),)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Image:
|
|
38
|
+
path: str | Path
|
|
39
|
+
detail: ImageDetail = ImageDetail.AUTO
|
|
40
|
+
|
|
41
|
+
async def parts(self) -> tuple[ContentPart, ...]:
|
|
42
|
+
location = str(self.path)
|
|
43
|
+
if location.startswith(("https://", "http://", "data:image/")):
|
|
44
|
+
return (ImagePart(location, self.detail),)
|
|
45
|
+
path = Path(self.path)
|
|
46
|
+
mime, _ = mimetypes.guess_type(path.name)
|
|
47
|
+
if mime not in ("image/png", "image/jpeg", "image/gif", "image/webp"):
|
|
48
|
+
raise ValueError("Images must be PNG, JPEG, GIF or WebP")
|
|
49
|
+
data = await asyncio.to_thread(path.read_bytes)
|
|
50
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
51
|
+
return (ImagePart(f"data:{mime};base64,{encoded}", self.detail),)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
type Attachment = File | Image
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def prepare_input(
|
|
58
|
+
content: str | Message,
|
|
59
|
+
*,
|
|
60
|
+
attachments: Iterable[Attachment] = (),
|
|
61
|
+
context: Mapping[str, Any] | None = None,
|
|
62
|
+
) -> tuple[Message, tuple[Message, ...]]:
|
|
63
|
+
message = user(content) if isinstance(content, str) else content
|
|
64
|
+
attachments = tuple(attachments)
|
|
65
|
+
if attachments:
|
|
66
|
+
if message.role != Role.USER:
|
|
67
|
+
raise ValueError("Attachments belong to user messages")
|
|
68
|
+
parts = (
|
|
69
|
+
[TextPart(message.content)]
|
|
70
|
+
if isinstance(message.content, str)
|
|
71
|
+
else list(message.content)
|
|
72
|
+
)
|
|
73
|
+
for attachment in attachments:
|
|
74
|
+
parts.extend(await attachment.parts())
|
|
75
|
+
message = User(tuple(parts), id=message.id)
|
|
76
|
+
contributions = []
|
|
77
|
+
for name, value in (context or {}).items():
|
|
78
|
+
if not isinstance(name, str):
|
|
79
|
+
raise TypeError("Context keys must be strings")
|
|
80
|
+
text = (
|
|
81
|
+
value
|
|
82
|
+
if isinstance(value, str)
|
|
83
|
+
else json.dumps(to_dict(freeze(value)), ensure_ascii=False)
|
|
84
|
+
)
|
|
85
|
+
contributions.append(system(f"Task context [{name}]:\n{text}"))
|
|
86
|
+
return message, tuple(contributions)
|
harnessed/core/budget.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
from pydantic import ConfigDict, Field
|
|
5
|
+
from pydantic.dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from harnessed.core.errors import ContextBudgetExceeded
|
|
8
|
+
from harnessed.core.model import ModelRequest
|
|
9
|
+
from harnessed.core.tokens import TokenCounter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def truncate(text: str, max_tokens: int, counter: TokenCounter) -> str:
|
|
13
|
+
low, high = 0, len(text)
|
|
14
|
+
while low < high:
|
|
15
|
+
middle = (low + high + 1) // 2
|
|
16
|
+
if counter.count_text(text[:middle]) <= max_tokens:
|
|
17
|
+
low = middle
|
|
18
|
+
else:
|
|
19
|
+
high = middle - 1
|
|
20
|
+
return text[:low]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(
|
|
24
|
+
frozen=True,
|
|
25
|
+
config=ConfigDict(strict=True, extra="forbid", allow_inf_nan=False, hide_input_in_errors=True),
|
|
26
|
+
)
|
|
27
|
+
class ContextBudget:
|
|
28
|
+
limit: Annotated[int, Field(ge=1)] | None = None
|
|
29
|
+
reserve_tokens: Annotated[int, Field(ge=0)] = 0
|
|
30
|
+
|
|
31
|
+
def window_limit(self, context_window: int | None) -> int | None:
|
|
32
|
+
if context_window is None:
|
|
33
|
+
return self.limit
|
|
34
|
+
return min(context_window, self.limit) if self.limit is not None else context_window
|
|
35
|
+
|
|
36
|
+
def output_reserve(self, request: ModelRequest) -> int:
|
|
37
|
+
return max(request.max_tokens or 0, self.reserve_tokens)
|
|
38
|
+
|
|
39
|
+
async def check(
|
|
40
|
+
self, request: ModelRequest, *, context_window: int | None, counter: TokenCounter
|
|
41
|
+
) -> None:
|
|
42
|
+
limit = self.window_limit(context_window)
|
|
43
|
+
if limit is None:
|
|
44
|
+
return
|
|
45
|
+
counted = counter.count_request(request)
|
|
46
|
+
used = await counted if inspect.isawaitable(counted) else counted
|
|
47
|
+
reserve = self.output_reserve(request)
|
|
48
|
+
if used + reserve > limit:
|
|
49
|
+
raise ContextBudgetExceeded(used, reserve, limit)
|