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.
Files changed (116) hide show
  1. harnessed/__init__.py +58 -0
  2. harnessed/__init__.pyi +68 -0
  3. harnessed/agent.py +132 -0
  4. harnessed/core/__init__.py +0 -0
  5. harnessed/core/approvals.py +291 -0
  6. harnessed/core/attachments.py +86 -0
  7. harnessed/core/budget.py +49 -0
  8. harnessed/core/buffering.py +49 -0
  9. harnessed/core/compaction.py +417 -0
  10. harnessed/core/errors.py +105 -0
  11. harnessed/core/event_kinds.py +50 -0
  12. harnessed/core/event_registry.py +114 -0
  13. harnessed/core/hooks.py +112 -0
  14. harnessed/core/messages.py +368 -0
  15. harnessed/core/model.py +248 -0
  16. harnessed/core/outputs.py +170 -0
  17. harnessed/core/plugin.py +118 -0
  18. harnessed/core/prompt/__init__.py +0 -0
  19. harnessed/core/prompt/context.py +84 -0
  20. harnessed/core/prompt/instructions.py +19 -0
  21. harnessed/core/prompt/system_prompt.py +42 -0
  22. harnessed/core/prompt/values.py +131 -0
  23. harnessed/core/runtime/__init__.py +0 -0
  24. harnessed/core/runtime/agent_loop.py +71 -0
  25. harnessed/core/runtime/approvals.py +231 -0
  26. harnessed/core/runtime/control.py +278 -0
  27. harnessed/core/runtime/executor.py +209 -0
  28. harnessed/core/runtime/handles.py +47 -0
  29. harnessed/core/runtime/inbox.py +132 -0
  30. harnessed/core/runtime/opener.py +77 -0
  31. harnessed/core/runtime/options.py +71 -0
  32. harnessed/core/runtime/output.py +124 -0
  33. harnessed/core/runtime/respond.py +69 -0
  34. harnessed/core/runtime/result.py +168 -0
  35. harnessed/core/runtime/services.py +38 -0
  36. harnessed/core/runtime/session.py +583 -0
  37. harnessed/core/runtime/stepper.py +162 -0
  38. harnessed/core/runtime/streaming.py +174 -0
  39. harnessed/core/runtime/turns.py +123 -0
  40. harnessed/core/session/__init__.py +0 -0
  41. harnessed/core/session/artifacts.py +81 -0
  42. harnessed/core/session/codec.py +202 -0
  43. harnessed/core/session/control.py +35 -0
  44. harnessed/core/session/events.py +731 -0
  45. harnessed/core/session/files.py +86 -0
  46. harnessed/core/session/history.py +391 -0
  47. harnessed/core/session/inbox.py +59 -0
  48. harnessed/core/session/repository.py +45 -0
  49. harnessed/core/session/session.py +537 -0
  50. harnessed/core/session/store.py +138 -0
  51. harnessed/core/session/turns.py +220 -0
  52. harnessed/core/stream.py +161 -0
  53. harnessed/core/tokens.py +78 -0
  54. harnessed/core/tools/__init__.py +11 -0
  55. harnessed/core/tools/arguments.py +98 -0
  56. harnessed/core/tools/context.py +61 -0
  57. harnessed/core/tools/decorator.py +53 -0
  58. harnessed/core/tools/output_limit.py +46 -0
  59. harnessed/core/tools/policy.py +18 -0
  60. harnessed/core/tools/references.py +68 -0
  61. harnessed/core/tools/registry.py +390 -0
  62. harnessed/core/tools/scheduling.py +93 -0
  63. harnessed/core/tracing.py +33 -0
  64. harnessed/errors.py +45 -0
  65. harnessed/events/__init__.py +53 -0
  66. harnessed/events/approval.py +7 -0
  67. harnessed/events/compaction.py +5 -0
  68. harnessed/events/context.py +6 -0
  69. harnessed/events/inbox.py +13 -0
  70. harnessed/events/message.py +11 -0
  71. harnessed/events/model.py +11 -0
  72. harnessed/events/output.py +7 -0
  73. harnessed/events/reasoning.py +5 -0
  74. harnessed/events/rule.py +7 -0
  75. harnessed/events/session.py +6 -0
  76. harnessed/events/spill.py +5 -0
  77. harnessed/events/step.py +5 -0
  78. harnessed/events/text.py +5 -0
  79. harnessed/events/tool.py +29 -0
  80. harnessed/events/turn.py +6 -0
  81. harnessed/hooks.py +29 -0
  82. harnessed/kernel/__init__.py +55 -0
  83. harnessed/kernel/context.py +344 -0
  84. harnessed/kernel/declarations.py +208 -0
  85. harnessed/kernel/di.py +121 -0
  86. harnessed/kernel/effects.py +45 -0
  87. harnessed/kernel/events.py +206 -0
  88. harnessed/kernel/hooks.py +97 -0
  89. harnessed/kernel/keys.py +20 -0
  90. harnessed/kernel/plugin.py +343 -0
  91. harnessed/kernel/spec.py +21 -0
  92. harnessed/kernel/state.py +40 -0
  93. harnessed/models/__init__.py +58 -0
  94. harnessed/models/pydantic_ai.py +532 -0
  95. harnessed/plugins/__init__.py +56 -0
  96. harnessed/plugins/compaction.py +114 -0
  97. harnessed/plugins/filesystem.py +283 -0
  98. harnessed/plugins/observations.py +31 -0
  99. harnessed/plugins/rules.py +210 -0
  100. harnessed/plugins/sessions.py +449 -0
  101. harnessed/plugins/shell/__init__.py +276 -0
  102. harnessed/plugins/shell/windows.py +132 -0
  103. harnessed/plugins/skills.py +408 -0
  104. harnessed/plugins/spill.py +233 -0
  105. harnessed/py.typed +0 -0
  106. harnessed/testing/__init__.py +17 -0
  107. harnessed/testing/assertions.py +20 -0
  108. harnessed/testing/compaction.py +30 -0
  109. harnessed/testing/fakes.py +184 -0
  110. harnessed/testing/models.py +173 -0
  111. harnessed/testing/pytest_plugin.py +26 -0
  112. harnessed-0.1.0.dist-info/METADATA +123 -0
  113. harnessed-0.1.0.dist-info/RECORD +116 -0
  114. harnessed-0.1.0.dist-info/WHEEL +5 -0
  115. harnessed-0.1.0.dist-info/licenses/LICENSE +21 -0
  116. 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)
@@ -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)