opencomputer 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 (51) hide show
  1. opencomputer/__init__.py +3 -0
  2. opencomputer/agent/__init__.py +1 -0
  3. opencomputer/agent/compaction.py +245 -0
  4. opencomputer/agent/config.py +108 -0
  5. opencomputer/agent/config_store.py +210 -0
  6. opencomputer/agent/injection.py +60 -0
  7. opencomputer/agent/loop.py +326 -0
  8. opencomputer/agent/memory.py +132 -0
  9. opencomputer/agent/prompt_builder.py +66 -0
  10. opencomputer/agent/prompts/base.j2 +23 -0
  11. opencomputer/agent/state.py +251 -0
  12. opencomputer/agent/step.py +31 -0
  13. opencomputer/cli.py +483 -0
  14. opencomputer/doctor.py +216 -0
  15. opencomputer/gateway/__init__.py +1 -0
  16. opencomputer/gateway/dispatch.py +89 -0
  17. opencomputer/gateway/protocol.py +84 -0
  18. opencomputer/gateway/server.py +77 -0
  19. opencomputer/gateway/wire_server.py +256 -0
  20. opencomputer/hooks/__init__.py +1 -0
  21. opencomputer/hooks/engine.py +79 -0
  22. opencomputer/hooks/runner.py +42 -0
  23. opencomputer/mcp/__init__.py +1 -0
  24. opencomputer/mcp/client.py +208 -0
  25. opencomputer/plugins/__init__.py +1 -0
  26. opencomputer/plugins/discovery.py +107 -0
  27. opencomputer/plugins/loader.py +155 -0
  28. opencomputer/plugins/registry.py +56 -0
  29. opencomputer/setup_wizard.py +235 -0
  30. opencomputer/skills/debug-python-import-error/SKILL.md +58 -0
  31. opencomputer/tools/__init__.py +1 -0
  32. opencomputer/tools/bash.py +78 -0
  33. opencomputer/tools/delegate.py +98 -0
  34. opencomputer/tools/glob.py +70 -0
  35. opencomputer/tools/grep.py +117 -0
  36. opencomputer/tools/read.py +81 -0
  37. opencomputer/tools/registry.py +69 -0
  38. opencomputer/tools/skill_manage.py +265 -0
  39. opencomputer/tools/write.py +58 -0
  40. opencomputer-0.1.0.dist-info/METADATA +190 -0
  41. opencomputer-0.1.0.dist-info/RECORD +51 -0
  42. opencomputer-0.1.0.dist-info/WHEEL +4 -0
  43. opencomputer-0.1.0.dist-info/entry_points.txt +3 -0
  44. plugin_sdk/__init__.py +66 -0
  45. plugin_sdk/channel_contract.py +74 -0
  46. plugin_sdk/core.py +129 -0
  47. plugin_sdk/hooks.py +80 -0
  48. plugin_sdk/injection.py +60 -0
  49. plugin_sdk/provider_contract.py +95 -0
  50. plugin_sdk/runtime_context.py +39 -0
  51. plugin_sdk/tool_contract.py +67 -0
@@ -0,0 +1,51 @@
1
+ opencomputer/__init__.py,sha256=gO2dZNgM6MLxz2pdq32RGQTD2vS27TpPASlsIMAb2TI,75
2
+ opencomputer/cli.py,sha256=Cb7BrTIwGZS1cb-_RAaMJXkXGgkSPoY9ryue5Dco2I0,16277
3
+ opencomputer/doctor.py,sha256=eyQDj1h6GYfwLxljjTlhQoyBDPwtIipOP8pPXyRNTqI,6752
4
+ opencomputer/setup_wizard.py,sha256=g-PFyayuu18s_3HXr3beR1Lbz4BcWg2vXUpVtwwevPI,8370
5
+ opencomputer/agent/__init__.py,sha256=rk4klIVmYuO_obiwCK1bsmrbKLH4VnLGRhi18zgzV90,64
6
+ opencomputer/agent/compaction.py,sha256=1W_3TrAFPSJBk8_26F9dWd2IZ7n5NoyDRCBWx9C3F-c,9374
7
+ opencomputer/agent/config.py,sha256=60cW2Dyv-YPFf6E_ZNQn9xb85RwbgdamvKpleCKNRtk,3150
8
+ opencomputer/agent/config_store.py,sha256=Ep1JCs1-A2fY8a_VfHksiQcMaCiaiU4TGccJgE2RdJQ,7360
9
+ opencomputer/agent/injection.py,sha256=CuNxfKCauIphSWJpO9js_cT6CMBPyWAa1fz4uI_15ig,2101
10
+ opencomputer/agent/loop.py,sha256=b_ip9eqfJ70kpZTmZLF8CHRbMu_fKWSEq5PsJ13ihOk,12356
11
+ opencomputer/agent/memory.py,sha256=DmjpEZxdPHK03Udevicwwzrxhg0xGJcSNbCr0nLLlAQ,4939
12
+ opencomputer/agent/prompt_builder.py,sha256=1_UqCBQx1WYTPITPptj-vyMpNYWpl9SP3goFeI4Tb9A,1825
13
+ opencomputer/agent/state.py,sha256=R1DamH-auT7SqvpopexEuzP5wxOJ3UmmMwgxVnVkvq8,8953
14
+ opencomputer/agent/step.py,sha256=gP0ByNVqeKU-PFAeI1Jlct1iSGXotg5l3Wb7lIxarEQ,823
15
+ opencomputer/agent/prompts/base.j2,sha256=DPR9XNJetxULTLOicUEdDEd7eT6UraAzTRsJJuPExrs,770
16
+ opencomputer/gateway/__init__.py,sha256=SbMLVhBOYJCRpPPXbJuLj5R-jpkv7eJmJtBzXxXlHBo,59
17
+ opencomputer/gateway/dispatch.py,sha256=X5RTO_pYIN60duU1eSFQ8qGeZn2e9qqsLklxpI_z1MY,3351
18
+ opencomputer/gateway/protocol.py,sha256=ccGdrxYOXjN-UVhBp8d1bTZQbUTrnDggDsnR7iPUtIE,2666
19
+ opencomputer/gateway/server.py,sha256=TePY6S2WRPSz8ZQxb1vmbDMSkPObLy-P8tMNGDSCSag,2717
20
+ opencomputer/gateway/wire_server.py,sha256=CeVlF-2WhQ0zTIboFMgjmbNNnD4G330VCYEq6fIoj14,8366
21
+ opencomputer/hooks/__init__.py,sha256=ppxxhfzdZj16u3Wk3RD0RxKvK-sE0ZexY8v1XgwKSLc,50
22
+ opencomputer/hooks/engine.py,sha256=PYs7GYAW8ztFbG8CDOLWEa7oN17oYhzsRge2iJu5pB8,2589
23
+ opencomputer/hooks/runner.py,sha256=_nBl67kBG5Qt7UpLqQvb15-wYL-xye1497EOVdJqK6Y,1108
24
+ opencomputer/mcp/__init__.py,sha256=OidTJFlWyzy7km3k9wDnNXzHrnJzAUcitwY0QcBkd9M,85
25
+ opencomputer/mcp/client.py,sha256=WvA1ovIyQ6A5EA28Wv5d5cr4S-TXIIlW-AcRQUgBceE,7582
26
+ opencomputer/plugins/__init__.py,sha256=gNdUELzcu3CZyS-xhE6hn6utS4Q9l20Nvly4DDx33eI,78
27
+ opencomputer/plugins/discovery.py,sha256=JjKOLRQbSzCKrDcgp_TREHvq_APsNwNak7yBUFpMNd4,3307
28
+ opencomputer/plugins/loader.py,sha256=o_IkpLG6GLayyiyfg0SayuTNS-BRG-wzKaeXkaUKp-Q,5376
29
+ opencomputer/plugins/registry.py,sha256=nOzE1ehcHvpoGrgvyqmOYADe0xY6lZwM60xiF7sTWUY,1992
30
+ opencomputer/skills/debug-python-import-error/SKILL.md,sha256=jBL3sHwtI-4rXbZC92qC5YV7xmP_fkVKGPETIaK5Pvw,2268
31
+ opencomputer/tools/__init__.py,sha256=ZbX8aocncUzMpXQ0sJNZc1Waxu_c0SZInSR3vLMlV5A,80
32
+ opencomputer/tools/bash.py,sha256=ZymfpRnir_9GbUWsTJy4BgmTcZeoDA22cz9mGciZMDE,2770
33
+ opencomputer/tools/delegate.py,sha256=Yn55lUnjlGzjUdoI9RWpHTm0vH_lNMll53l4N0RgW8c,3962
34
+ opencomputer/tools/glob.py,sha256=JXl814MKY0O9hvAfzCqrr38xxXzurQ7Zk7j7fa54L_w,2369
35
+ opencomputer/tools/grep.py,sha256=PCz1xqSNCbU6_guLIwkv_Dj-w1v_zqT7okul4vRmv4k,4081
36
+ opencomputer/tools/read.py,sha256=FRpVndWdb7I09yF5v-GXOE71oqjfe1Vs6dwQbyY2DcU,2855
37
+ opencomputer/tools/registry.py,sha256=97niCcd7cuMtPql4Wilrtf6bv1kayqsedPiyDXd2cco,2190
38
+ opencomputer/tools/skill_manage.py,sha256=CZjBCfkfil8-YAL9jO4AuLRMbfFoQz9NsL4fyjbD9rs,10212
39
+ opencomputer/tools/write.py,sha256=704Qjng-SCj3MlIJu7uul8vSTU1dH_2pkQ9Fsda-Y9g,2010
40
+ plugin_sdk/__init__.py,sha256=UCsA-Of-gdrXdQBXxzFdkYKZ0o64nWcpBnsANCXRCMs,1604
41
+ plugin_sdk/channel_contract.py,sha256=MJ9iaPG7QMTRD3orKkKtKSUtS-kCjEeNc--snl1NrRg,2448
42
+ plugin_sdk/core.py,sha256=mulzBrjhcvPXTvjLrl0Q8sEhnZWZeq2NuG0G8vkrBQw,3683
43
+ plugin_sdk/hooks.py,sha256=nhvjd4qM7T0BZ2G-G5N70VReJ0mHkCyOxKh5TguQ1JY,2589
44
+ plugin_sdk/injection.py,sha256=r2WPZYPcoaYn1gzOLzb9Ni5ZDbNkNg8brFsJySXr9jw,2056
45
+ plugin_sdk/provider_contract.py,sha256=hybjR2AhPRs3rv-1XavaJOP5U4KXkuQ3lt667dOESaE,2716
46
+ plugin_sdk/runtime_context.py,sha256=UOfEsAnIvM5LtX0DVZqw67HvH83KinYQTp8KpAzUUZk,1381
47
+ plugin_sdk/tool_contract.py,sha256=YJ0AsjhG8EiBeTfB5MwHu6nQ8wN16p7vPiBTT9vGas4,1944
48
+ opencomputer-0.1.0.dist-info/METADATA,sha256=TrL-2aVSZ5STLC30LfWRtG00uuml65klT8XXlG3zUpU,6669
49
+ opencomputer-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
50
+ opencomputer-0.1.0.dist-info/entry_points.txt,sha256=hvAtUBkItM2YqIhOp3Kh3tOc5tRAM3qAZBn-qG0gzCo,82
51
+ opencomputer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ oc = opencomputer.cli:main
3
+ opencomputer = opencomputer.cli:main
plugin_sdk/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """
2
+ OpenComputer Plugin SDK — the ONLY public contract for plugins.
3
+
4
+ Third-party plugins must import from `plugin_sdk/*` exclusively. Never
5
+ import from `opencomputer/**` directly — those modules are internal
6
+ and may change without warning. The SDK is versioned and evolves with
7
+ backwards-compatible guarantees across minor releases.
8
+ """
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ from plugin_sdk.channel_contract import BaseChannelAdapter
13
+ from plugin_sdk.core import (
14
+ Message,
15
+ MessageEvent,
16
+ Platform,
17
+ PluginManifest,
18
+ Role,
19
+ SendResult,
20
+ StopReason,
21
+ ToolCall,
22
+ ToolResult,
23
+ )
24
+ from plugin_sdk.hooks import HookContext, HookDecision, HookEvent, HookHandler, HookSpec
25
+ from plugin_sdk.injection import DynamicInjectionProvider, InjectionContext
26
+ from plugin_sdk.provider_contract import (
27
+ BaseProvider,
28
+ ProviderResponse,
29
+ StreamEvent,
30
+ Usage,
31
+ )
32
+ from plugin_sdk.runtime_context import DEFAULT_RUNTIME_CONTEXT, RuntimeContext
33
+ from plugin_sdk.tool_contract import BaseTool, ToolSchema
34
+
35
+ __all__ = [
36
+ "__version__",
37
+ # core types
38
+ "Role",
39
+ "Message",
40
+ "ToolCall",
41
+ "ToolResult",
42
+ "Platform",
43
+ "MessageEvent",
44
+ "SendResult",
45
+ "PluginManifest",
46
+ "StopReason",
47
+ # contracts
48
+ "BaseTool",
49
+ "ToolSchema",
50
+ "BaseProvider",
51
+ "ProviderResponse",
52
+ "StreamEvent",
53
+ "Usage",
54
+ "BaseChannelAdapter",
55
+ # hooks
56
+ "HookEvent",
57
+ "HookContext",
58
+ "HookDecision",
59
+ "HookHandler",
60
+ "HookSpec",
61
+ # runtime + injection
62
+ "RuntimeContext",
63
+ "DEFAULT_RUNTIME_CONTEXT",
64
+ "DynamicInjectionProvider",
65
+ "InjectionContext",
66
+ ]
@@ -0,0 +1,74 @@
1
+ """
2
+ Channel contract — what plugin authors implement to add a messaging channel.
3
+
4
+ A channel adapter translates between a specific messaging platform
5
+ (Telegram, Discord, Slack, ...) and OpenComputer's common MessageEvent
6
+ format. The gateway is platform-agnostic; adapters absorb all the
7
+ platform-specific weirdness.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from abc import ABC, abstractmethod
13
+ from collections.abc import Awaitable, Callable
14
+ from typing import Any
15
+
16
+ from plugin_sdk.core import MessageEvent, Platform, SendResult
17
+
18
+
19
+ class BaseChannelAdapter(ABC):
20
+ """Base class for a messaging channel plugin."""
21
+
22
+ #: The platform this adapter serves.
23
+ platform: Platform
24
+
25
+ #: Max message length this platform accepts (in chars unless noted).
26
+ max_message_length: int = 10_000
27
+
28
+ def __init__(self, config: dict[str, Any]) -> None:
29
+ self.config = config
30
+ self._message_handler: (
31
+ Callable[[MessageEvent], Awaitable[str | None]] | None
32
+ ) = None
33
+
34
+ def set_message_handler(
35
+ self, handler: Callable[[MessageEvent], Awaitable[str | None]]
36
+ ) -> None:
37
+ """Called by the gateway to register its inbound handler."""
38
+ self._message_handler = handler
39
+
40
+ async def handle_message(self, event: MessageEvent) -> None:
41
+ """Adapters call this when an inbound message arrives. Dispatches to the gateway."""
42
+ if self._message_handler is None:
43
+ return
44
+ response = await self._message_handler(event)
45
+ if response:
46
+ await self.send(event.chat_id, response)
47
+
48
+ @abstractmethod
49
+ async def connect(self) -> bool:
50
+ """Connect to the platform and start listening. Return True on success."""
51
+ ...
52
+
53
+ @abstractmethod
54
+ async def disconnect(self) -> None:
55
+ """Stop listening and clean up."""
56
+ ...
57
+
58
+ @abstractmethod
59
+ async def send(self, chat_id: str, text: str, **kwargs: Any) -> SendResult:
60
+ """Send a text message to a chat."""
61
+ ...
62
+
63
+ async def send_typing(self, chat_id: str) -> None:
64
+ """Send a typing indicator. Optional — default is a no-op."""
65
+ return None
66
+
67
+ async def send_image(
68
+ self, chat_id: str, image_url: str, caption: str = ""
69
+ ) -> SendResult:
70
+ """Send an image. Optional — default raises NotImplementedError."""
71
+ raise NotImplementedError(f"{self.platform} adapter has no image support")
72
+
73
+
74
+ __all__ = ["BaseChannelAdapter"]
plugin_sdk/core.py ADDED
@@ -0,0 +1,129 @@
1
+ """
2
+ Core public types — the canonical vocabulary plugins use.
3
+
4
+ These types are the STABLE contract. Changes here are breaking changes
5
+ and require a major version bump of plugin_sdk.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import Any, Literal
13
+
14
+ # ─── Message / conversation primitives ─────────────────────────────────
15
+
16
+ Role = Literal["system", "user", "assistant", "tool"]
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Message:
21
+ """A single conversation message — canonical form used everywhere internally."""
22
+
23
+ role: Role
24
+ content: str
25
+ tool_call_id: str | None = None
26
+ tool_calls: list[ToolCall] | None = None
27
+ name: str | None = None # for tool messages, the tool name
28
+ reasoning: str | None = None # extended thinking, if supported
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class ToolCall:
33
+ """A request from the model to invoke a tool."""
34
+
35
+ id: str
36
+ name: str
37
+ arguments: dict[str, Any]
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class ToolResult:
42
+ """The result of executing a tool call."""
43
+
44
+ tool_call_id: str
45
+ content: str
46
+ is_error: bool = False
47
+
48
+
49
+ # ─── Platform / channel primitives ─────────────────────────────────────
50
+
51
+
52
+ class Platform(str, Enum):
53
+ """Supported messaging platforms. Plugins can register new ones."""
54
+
55
+ CLI = "cli"
56
+ TELEGRAM = "telegram"
57
+ DISCORD = "discord"
58
+ SLACK = "slack"
59
+ WHATSAPP = "whatsapp"
60
+ SIGNAL = "signal"
61
+ IMESSAGE = "imessage"
62
+ WEB = "web"
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class MessageEvent:
67
+ """Platform-agnostic inbound message — the common format produced by every adapter."""
68
+
69
+ platform: Platform
70
+ chat_id: str
71
+ user_id: str
72
+ text: str
73
+ timestamp: float # unix timestamp
74
+ attachments: list[str] = field(default_factory=list) # file paths or URLs
75
+ metadata: dict[str, Any] = field(default_factory=dict)
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class SendResult:
80
+ """Outbound delivery result from a channel adapter."""
81
+
82
+ success: bool
83
+ message_id: str | None = None
84
+ error: str | None = None
85
+
86
+
87
+ # ─── Plugin manifest ───────────────────────────────────────────────────
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class PluginManifest:
92
+ """Metadata for a plugin — parsed from plugin.json at discovery time."""
93
+
94
+ id: str
95
+ name: str
96
+ version: str
97
+ description: str = ""
98
+ author: str = ""
99
+ homepage: str = ""
100
+ license: str = "MIT"
101
+ kind: Literal["channel", "provider", "tool", "skill", "mixed"] = "mixed"
102
+ entry: str = "" # path to the entry module, relative to plugin root
103
+
104
+
105
+ # ─── Stop reasons ──────────────────────────────────────────────────────
106
+
107
+
108
+ class StopReason(str, Enum):
109
+ """Why a conversation step ended."""
110
+
111
+ END_TURN = "end_turn" # model produced final response, no more tool calls
112
+ TOOL_USE = "tool_use" # model wants to call tools — loop continues
113
+ MAX_TOKENS = "max_tokens" # hit output limit
114
+ INTERRUPTED = "interrupted" # user cancelled
115
+ BUDGET_EXHAUSTED = "budget_exhausted" # iteration budget spent
116
+ ERROR = "error" # unrecoverable error
117
+
118
+
119
+ __all__ = [
120
+ "Role",
121
+ "Message",
122
+ "ToolCall",
123
+ "ToolResult",
124
+ "Platform",
125
+ "MessageEvent",
126
+ "SendResult",
127
+ "PluginManifest",
128
+ "StopReason",
129
+ ]
plugin_sdk/hooks.py ADDED
@@ -0,0 +1,80 @@
1
+ """
2
+ Hook primitives — for plugins that want to intercept lifecycle events.
3
+
4
+ Hooks are fire-and-forget event handlers. Critical rule (from kimi-cli):
5
+ post-action hooks MUST NOT block the agent loop. Use async + let the
6
+ loop move on. The hook engine discards exceptions silently (logs them).
7
+
8
+ Available events:
9
+ PreToolUse — fires before any tool runs (can approve/block/modify)
10
+ PostToolUse — fires after any tool runs (log, inspect result)
11
+ Stop — fires when the model stops asking for tools (can force continue)
12
+ SessionStart — fires once when a new conversation begins
13
+ SessionEnd — fires when conversation ends
14
+ UserPromptSubmit — fires when the user submits a message
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Awaitable, Callable
20
+ from dataclasses import dataclass
21
+ from enum import Enum
22
+ from typing import Literal
23
+
24
+ from plugin_sdk.core import Message, ToolCall, ToolResult
25
+ from plugin_sdk.runtime_context import RuntimeContext
26
+
27
+
28
+ class HookEvent(str, Enum):
29
+ PRE_TOOL_USE = "PreToolUse"
30
+ POST_TOOL_USE = "PostToolUse"
31
+ STOP = "Stop"
32
+ SESSION_START = "SessionStart"
33
+ SESSION_END = "SessionEnd"
34
+ USER_PROMPT_SUBMIT = "UserPromptSubmit"
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class HookContext:
39
+ """Data passed to every hook invocation. Read-only."""
40
+
41
+ event: HookEvent
42
+ session_id: str
43
+ tool_call: ToolCall | None = None
44
+ tool_result: ToolResult | None = None
45
+ message: Message | None = None
46
+ #: Runtime flags for this invocation (plan_mode, yolo_mode, custom).
47
+ #: None for backwards compatibility with hooks written before this field.
48
+ runtime: RuntimeContext | None = None
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class HookDecision:
53
+ """A hook's response. PreToolUse hooks use `decision` to approve/block."""
54
+
55
+ decision: Literal["approve", "block", "pass"] = "pass"
56
+ reason: str = ""
57
+ modified_message: str = "" # if set, injected as a system reminder
58
+
59
+
60
+ # Hook handler is an async callable: (ctx) -> HookDecision or None (= "pass")
61
+ HookHandler = Callable[[HookContext], Awaitable[HookDecision | None]]
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class HookSpec:
66
+ """What plugins register — one event + one handler + an optional matcher."""
67
+
68
+ event: HookEvent
69
+ handler: HookHandler
70
+ matcher: str | None = None # regex over tool names for PreToolUse/PostToolUse
71
+ fire_and_forget: bool = True # true for post-action hooks
72
+
73
+
74
+ __all__ = [
75
+ "HookEvent",
76
+ "HookContext",
77
+ "HookDecision",
78
+ "HookHandler",
79
+ "HookSpec",
80
+ ]
@@ -0,0 +1,60 @@
1
+ """
2
+ Dynamic injection providers — cross-cutting system-prompt modifiers.
3
+
4
+ A provider declares a piece of text to inject into the system prompt when
5
+ certain runtime conditions apply (e.g. plan mode active). The agent loop
6
+ queries all registered providers at the start of each turn; whichever
7
+ return non-empty strings get appended to the system prompt.
8
+
9
+ This is kimi-cli's pattern — keeps cross-cutting concerns (plan mode, yolo
10
+ mode, custom modes) out of the main loop as if-branches.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from abc import ABC, abstractmethod
16
+ from dataclasses import dataclass
17
+
18
+ from plugin_sdk.core import Message
19
+ from plugin_sdk.runtime_context import RuntimeContext
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class InjectionContext:
24
+ """Read-only snapshot passed to each provider's collect() call."""
25
+
26
+ #: Full message history so far (same list the LLM will see this turn).
27
+ messages: tuple[Message, ...]
28
+ #: Per-invocation flags (plan_mode, yolo_mode, etc.).
29
+ runtime: RuntimeContext
30
+ #: Session id — useful for session-scoped caches or per-chat behaviors.
31
+ session_id: str = ""
32
+
33
+
34
+ class DynamicInjectionProvider(ABC):
35
+ """Base class for providers that inject text into the system prompt.
36
+
37
+ Implement `collect()`. Return a string (the injection) or None/empty
38
+ (this provider is not applicable this turn).
39
+
40
+ `priority` orders providers in the final prompt — lower first.
41
+ `provider_id` must be unique per registration; it's also used for
42
+ deterministic ordering when two providers share a priority.
43
+ """
44
+
45
+ #: Lower runs first. Plan mode is 10, yolo is 20, user-added modes 50+.
46
+ priority: int = 100
47
+
48
+ @property
49
+ @abstractmethod
50
+ def provider_id(self) -> str:
51
+ """Unique id per provider. Used for dedup + ordering stability."""
52
+ ...
53
+
54
+ @abstractmethod
55
+ def collect(self, ctx: InjectionContext) -> str | None:
56
+ """Return injection text or None if this provider doesn't apply."""
57
+ ...
58
+
59
+
60
+ __all__ = ["DynamicInjectionProvider", "InjectionContext"]
@@ -0,0 +1,95 @@
1
+ """
2
+ Provider contract — what plugin authors implement to add an LLM provider.
3
+
4
+ Providers wrap model APIs (Anthropic, OpenAI, OpenRouter, etc.) behind a
5
+ single interface the agent loop depends on. The agent never imports
6
+ anthropic/openai SDKs directly — it only uses BaseProvider.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+ from collections.abc import AsyncIterator
13
+ from dataclasses import dataclass
14
+ from typing import Literal
15
+
16
+ from plugin_sdk.core import Message
17
+ from plugin_sdk.tool_contract import ToolSchema
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Usage:
22
+ """Token counts from a single LLM call."""
23
+
24
+ input_tokens: int = 0
25
+ output_tokens: int = 0
26
+ cache_read_tokens: int = 0
27
+ cache_write_tokens: int = 0
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ProviderResponse:
32
+ """The result of calling `provider.complete(...)`."""
33
+
34
+ message: Message # the assistant message, possibly containing tool_calls
35
+ stop_reason: str # "end_turn" | "tool_use" | "max_tokens" | ...
36
+ usage: Usage
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class StreamEvent:
41
+ """One event emitted by `provider.stream_complete()`.
42
+
43
+ Types:
44
+ - "text_delta": incremental text chunk (`text` field)
45
+ - "tool_call": full tool call has been assembled (`tool_call` field)
46
+ - "done": streaming finished (`response` field carries the final ProviderResponse)
47
+ """
48
+
49
+ kind: Literal["text_delta", "tool_call", "done"]
50
+ text: str = ""
51
+ response: ProviderResponse | None = None
52
+
53
+
54
+ class BaseProvider(ABC):
55
+ """Base class for an LLM provider plugin."""
56
+
57
+ name: str = ""
58
+ default_model: str = ""
59
+
60
+ @abstractmethod
61
+ async def complete(
62
+ self,
63
+ *,
64
+ model: str,
65
+ messages: list[Message],
66
+ system: str = "",
67
+ tools: list[ToolSchema] | None = None,
68
+ max_tokens: int = 4096,
69
+ temperature: float = 1.0,
70
+ stream: bool = False,
71
+ ) -> ProviderResponse:
72
+ """Send messages to the provider, return a single ProviderResponse."""
73
+ ...
74
+
75
+ @abstractmethod
76
+ async def stream_complete(
77
+ self,
78
+ *,
79
+ model: str,
80
+ messages: list[Message],
81
+ system: str = "",
82
+ tools: list[ToolSchema] | None = None,
83
+ max_tokens: int = 4096,
84
+ temperature: float = 1.0,
85
+ ) -> AsyncIterator[StreamEvent]:
86
+ """Stream the response.
87
+
88
+ Yields StreamEvent objects in order. Final event has kind="done"
89
+ and carries the complete ProviderResponse (including aggregated text
90
+ and any tool calls). Text chunks arrive as kind="text_delta".
91
+ """
92
+ ...
93
+
94
+
95
+ __all__ = ["BaseProvider", "ProviderResponse", "Usage", "StreamEvent"]
@@ -0,0 +1,39 @@
1
+ """
2
+ RuntimeContext — per-turn flags passed through the agent loop.
3
+
4
+ The CLI / caller builds this once per invocation. The agent loop passes it
5
+ to InjectionProviders (so they can decide whether to fire) and to Hooks
6
+ (so they can decide whether to block). `delegate` propagates it to
7
+ subagents, so modes like `--plan` apply to the whole subagent tree.
8
+
9
+ Frozen dataclass — safe to share across tasks / threads.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class RuntimeContext:
20
+ """Flags that cross-cutting modes read. Immutable per invocation."""
21
+
22
+ #: Plan mode — agent should describe what it would do without executing
23
+ #: destructive tools. Enforced both via injection (prompt) and hook (hard-block).
24
+ plan_mode: bool = False
25
+
26
+ #: Yolo mode — auto-approve dangerous operations. Mutually exclusive with plan_mode
27
+ #: (we enforce this in the CLI; if both set, plan_mode wins).
28
+ yolo_mode: bool = False
29
+
30
+ #: Escape hatch for third-party plugins to add their own modes without
31
+ #: forcing an SDK version bump.
32
+ custom: dict[str, Any] = field(default_factory=dict)
33
+
34
+
35
+ #: A sentinel "no flags" default — useful when callers don't care about modes.
36
+ DEFAULT_RUNTIME_CONTEXT = RuntimeContext()
37
+
38
+
39
+ __all__ = ["RuntimeContext", "DEFAULT_RUNTIME_CONTEXT"]
@@ -0,0 +1,67 @@
1
+ """
2
+ Tool contract — what plugin authors implement to add a new tool.
3
+
4
+ A tool is any callable the agent can invoke: Read, Write, Bash, etc.
5
+ Plugins can add new ones by subclassing `BaseTool` and registering
6
+ via `register_plugin(..., tools=[MyTool])`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from plugin_sdk.core import ToolCall, ToolResult
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class ToolSchema:
20
+ """OpenAI-compatible JSON schema for a tool."""
21
+
22
+ name: str
23
+ description: str
24
+ parameters: dict[str, Any] # JSON Schema object
25
+
26
+ def to_openai_format(self) -> dict[str, Any]:
27
+ """Convert to the dict format the OpenAI API expects."""
28
+ return {
29
+ "type": "function",
30
+ "function": {
31
+ "name": self.name,
32
+ "description": self.description,
33
+ "parameters": self.parameters,
34
+ },
35
+ }
36
+
37
+ def to_anthropic_format(self) -> dict[str, Any]:
38
+ """Convert to the dict format the Anthropic API expects."""
39
+ return {
40
+ "name": self.name,
41
+ "description": self.description,
42
+ "input_schema": self.parameters,
43
+ }
44
+
45
+
46
+ class BaseTool(ABC):
47
+ """Base class for a tool. Subclass and implement `schema` + `execute`."""
48
+
49
+ #: Whether this tool is safe to run in parallel with other parallel-safe tools.
50
+ parallel_safe: bool = False
51
+
52
+ #: Maximum size of the result string (longer is truncated with a notice).
53
+ max_result_size: int = 100_000
54
+
55
+ @property
56
+ @abstractmethod
57
+ def schema(self) -> ToolSchema:
58
+ """Return the JSON schema describing this tool's input."""
59
+ ...
60
+
61
+ @abstractmethod
62
+ async def execute(self, call: ToolCall) -> ToolResult:
63
+ """Actually run the tool. Must handle its own errors — never raise."""
64
+ ...
65
+
66
+
67
+ __all__ = ["ToolSchema", "BaseTool"]