induscode 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 (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,226 @@
1
+ """Working-memory capability — read and update a persistent scratch note the
2
+ agent carries across turns.
3
+
4
+ Status: minimal in-memory implementation + clearly-typed seam for the
5
+ framework memory subsystem.
6
+
7
+ The framework's ``indusagi.memory`` facade is not yet populated with a public
8
+ working-memory store, so this card ships a self-contained, framework-agnostic
9
+ implementation: a single mutable text buffer the agent overwrites or appends
10
+ to, scoped to one built capability (one session). When the framework exposes a
11
+ persistent memory store, a host adapts it to the :class:`MemoryStore` port and
12
+ injects it via :attr:`DeckContext.framework` under :data:`MEMORY_HANDLE_KEY` —
13
+ the :data:`Capability` surface and the tool's wire contract do not change.
14
+
15
+ TODO(framework-memory): adapt ``indusagi.memory`` once it exports a public
16
+ working-memory store; read it from ``ctx.framework[MEMORY_HANDLE_KEY]``.
17
+
18
+ The single tool keys behavior on an ``action`` discriminant:
19
+
20
+ - ``read`` — return the current working-memory note.
21
+ - ``replace`` — overwrite the note with the supplied ``content``.
22
+ - ``append`` — add a line to the end of the note.
23
+
24
+ Port note: the TypeBox schema becomes a dict-literal JSON Schema; an unknown
25
+ ``action`` yields a defensive ``isError`` result rather than relying on
26
+ upstream validation.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from collections.abc import Mapping
32
+ from dataclasses import dataclass
33
+ from typing import Literal, Protocol
34
+
35
+ from indusagi.agent import AgentToolResult
36
+ from indusagi.ai import TextContent
37
+
38
+ from ..contract import Capability, CapabilityCard, DeckContext, Schema, capability_id
39
+
40
+ __all__ = [
41
+ "InMemoryStore",
42
+ "MEMORY_HANDLE_KEY",
43
+ "MemoryDetails",
44
+ "MemoryStore",
45
+ "build_memory_capability",
46
+ "memory_card",
47
+ ]
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Store (in-memory, card-owned) + injection seam
52
+ # ---------------------------------------------------------------------------
53
+
54
+ #: Key under which a host may wire a framework-backed memory store in later.
55
+ MEMORY_HANDLE_KEY = "memoryStore"
56
+
57
+
58
+ class MemoryStore(Protocol):
59
+ """The narrow port the memory capability binds to.
60
+
61
+ The in-memory default below satisfies it; a future framework-backed store
62
+ can be adapted to the same three methods and injected through the context.
63
+ """
64
+
65
+ def read(self) -> str: ...
66
+
67
+ def replace(self, content: str) -> None: ...
68
+
69
+ def append(self, line: str) -> None: ...
70
+
71
+
72
+ class InMemoryStore:
73
+ """A trivial in-process working-memory buffer, one per built capability."""
74
+
75
+ def __init__(self) -> None:
76
+ self._buffer = ""
77
+
78
+ def read(self) -> str:
79
+ return self._buffer
80
+
81
+ def replace(self, content: str) -> None:
82
+ self._buffer = content
83
+
84
+ def append(self, line: str) -> None:
85
+ self._buffer = line if not self._buffer else f"{self._buffer}\n{line}"
86
+
87
+
88
+ def _read_store(ctx: DeckContext) -> MemoryStore:
89
+ handle = (ctx.framework or {}).get(MEMORY_HANDLE_KEY)
90
+ if (
91
+ handle is not None
92
+ and callable(getattr(handle, "read", None))
93
+ and callable(getattr(handle, "replace", None))
94
+ and callable(getattr(handle, "append", None))
95
+ ):
96
+ return handle # type: ignore[return-value] — structural check above
97
+ return InMemoryStore()
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Parameters (dict-literal JSON Schema)
102
+ # ---------------------------------------------------------------------------
103
+
104
+ _MEMORY_PARAMS: Schema = {
105
+ "type": "object",
106
+ "properties": {
107
+ "action": {
108
+ "type": "string",
109
+ "enum": ["read", "replace", "append"],
110
+ "description": (
111
+ "`read` returns the note; `replace` overwrites it; `append` adds a "
112
+ "line to the end."
113
+ ),
114
+ },
115
+ "content": {
116
+ "type": "string",
117
+ "description": (
118
+ "New note body (for `replace`) or the line to add (for `append`). "
119
+ "Ignored for `read`."
120
+ ),
121
+ },
122
+ },
123
+ "required": ["action"],
124
+ "additionalProperties": False,
125
+ }
126
+
127
+
128
+ @dataclass(frozen=True, slots=True, kw_only=True)
129
+ class MemoryDetails:
130
+ """Structured detail returned alongside the model-facing content."""
131
+
132
+ action: Literal["read", "replace", "append"]
133
+ ok: bool
134
+ # Length of the note after the call, for quick status.
135
+ length: int
136
+
137
+
138
+ _MEMORY_DESCRIPTION = (
139
+ "Keep a small working-memory note that persists across turns — durable facts, decisions, "
140
+ "or reminders you want to retain even after older messages scroll out of context. Use "
141
+ '`action:"read"` to recall it, `action:"replace"` to rewrite it from scratch, and '
142
+ '`action:"append"` to add a single line. Keep it concise; it is a scratchpad, not a log.'
143
+ )
144
+
145
+ _HEADINGS: Mapping[str, str] = {
146
+ "read": "Working memory:",
147
+ "replace": "Working memory replaced:",
148
+ "append": "Working memory appended:",
149
+ }
150
+
151
+
152
+ def _field(params: object, key: str) -> object:
153
+ if isinstance(params, Mapping):
154
+ return params.get(key)
155
+ return getattr(params, key, None)
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Capability builder
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ class _MemoryCapability:
164
+ """The live working-memory capability — structurally an ``AgentTool``."""
165
+
166
+ name = "memory"
167
+ label = "Working memory"
168
+ description = _MEMORY_DESCRIPTION
169
+ parameters: Schema = _MEMORY_PARAMS
170
+
171
+ def __init__(self, store: MemoryStore) -> None:
172
+ self._store = store
173
+
174
+ async def execute(
175
+ self,
176
+ tool_call_id: str,
177
+ params: object,
178
+ signal: object = None,
179
+ on_update: object = None,
180
+ ) -> AgentToolResult:
181
+ del tool_call_id, signal, on_update
182
+ action = _field(params, "action")
183
+ if action not in ("read", "replace", "append"):
184
+ return AgentToolResult(
185
+ content=(
186
+ TextContent(
187
+ text='`action` must be "read", "replace", or "append".'
188
+ ),
189
+ ),
190
+ details=None,
191
+ isError=True,
192
+ )
193
+ content = _field(params, "content")
194
+ if action == "replace":
195
+ self._store.replace(content if isinstance(content, str) else "")
196
+ elif action == "append" and isinstance(content, str) and content:
197
+ self._store.append(content)
198
+ note = self._store.read()
199
+ body = "(empty)" if not note else note
200
+ return AgentToolResult(
201
+ content=(TextContent(text=f"{_HEADINGS[action]}\n{body}"),),
202
+ details=MemoryDetails(action=action, ok=True, length=len(note)),
203
+ )
204
+
205
+
206
+ def build_memory_capability(ctx: DeckContext) -> _MemoryCapability:
207
+ """Build the working-memory capability, binding it to an injected store
208
+ when one is wired into the context, or a fresh in-memory store otherwise.
209
+
210
+ :param ctx: the deck context; an optional store is read from
211
+ ``ctx.framework[MEMORY_HANDLE_KEY]``
212
+ """
213
+ return _MemoryCapability(_read_store(ctx))
214
+
215
+
216
+ def _build(ctx: DeckContext) -> Capability:
217
+ return build_memory_capability(ctx)
218
+
219
+
220
+ #: Catalog row for the working-memory capability.
221
+ memory_card = CapabilityCard(
222
+ id=capability_id("memory"),
223
+ title="Working memory",
224
+ summary="Read and update a persistent scratch note that survives across turns.",
225
+ build=_build,
226
+ )
@@ -0,0 +1,280 @@
1
+ """SaaS-action capability — a thin wrapper over the framework's connector
2
+ gateway (Composio-style remote tool execution).
3
+
4
+ The heavy lifting (authenticating to a vendor, resolving toolkit scopes,
5
+ executing a remote tool slug) lives in the framework's connectors layer. This
6
+ card does NOT re-implement any of it; it adapts an injected gateway handle to
7
+ the deck's :data:`Capability` shape so the agent can invoke remote SaaS
8
+ actions by slug. When no gateway is wired into the context the card still
9
+ builds and returns a typed stub, so the deck assembles in every environment.
10
+
11
+ The single tool keys behavior on an ``action`` discriminant:
12
+
13
+ - ``discover`` — list executable remote tool slugs (optionally filtered).
14
+ - ``execute`` — run one remote tool slug with arguments.
15
+
16
+ Vendor-defined identifiers (uppercase tool slugs like ``GITHUB_CREATE_ISSUE``)
17
+ are passed through verbatim — they are part of the connector's wire contract.
18
+
19
+ Port note: the TypeBox schema becomes a dict-literal JSON Schema; the
20
+ ``slug``-required-for-execute check was already a runtime guard in TS and
21
+ ports as-is.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from collections.abc import Awaitable, Mapping
28
+ from dataclasses import dataclass
29
+ from typing import Literal, Protocol
30
+
31
+ from indusagi.agent import AgentToolResult
32
+ from indusagi.ai import TextContent
33
+
34
+ from ..contract import Capability, CapabilityCard, DeckContext, Schema, capability_id
35
+
36
+ __all__ = [
37
+ "RemoteExecution",
38
+ "RemoteToolSummary",
39
+ "SAAS_GATEWAY_KEY",
40
+ "SaasDetails",
41
+ "SaasGatewayPort",
42
+ "build_saas_capability",
43
+ "saas_card",
44
+ ]
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Gateway handle (injected via DeckContext.framework)
49
+ # ---------------------------------------------------------------------------
50
+
51
+ #: Key under which a host wires a live SaaS gateway into the deck context.
52
+ #: (Kept camelCase — it names a context-bag entry, mirroring the TS wiring.)
53
+ SAAS_GATEWAY_KEY = "saasGateway"
54
+
55
+
56
+ @dataclass(frozen=True, slots=True, kw_only=True)
57
+ class RemoteToolSummary:
58
+ """One executable remote tool advertised by the connector."""
59
+
60
+ # Vendor-defined slug, passed through verbatim (e.g. `GITHUB_CREATE_ISSUE`).
61
+ slug: str
62
+ # Human-readable description from the connector, if any.
63
+ description: str | None = None
64
+
65
+
66
+ @dataclass(frozen=True, slots=True, kw_only=True)
67
+ class RemoteExecution:
68
+ """The outcome of executing one remote tool slug."""
69
+
70
+ ok: bool
71
+ # Connector payload, surfaced to the model as serialized text.
72
+ data: object
73
+
74
+
75
+ class SaasGatewayPort(Protocol):
76
+ """The slice of a framework SaaS gateway this card adapts.
77
+
78
+ Deliberately narrow — the deck only needs to list and execute remote
79
+ tools. The framework's full gateway (connection lifecycle, scope planning,
80
+ status reporting) is a superset; a host adapts it to this shape when
81
+ wiring it in.
82
+ """
83
+
84
+ def discover(
85
+ self, filter: Mapping[str, object] | None = None
86
+ ) -> Awaitable[list[RemoteToolSummary]]:
87
+ """List executable remote tools, optionally filtered by ``toolkits``
88
+ or ``query`` entries of the filter mapping."""
89
+ ...
90
+
91
+ def execute(
92
+ self, slug: str, args: Mapping[str, object]
93
+ ) -> Awaitable[RemoteExecution]:
94
+ """Execute one remote tool slug with arguments."""
95
+ ...
96
+
97
+
98
+ def _read_gateway(ctx: DeckContext) -> SaasGatewayPort | None:
99
+ handle = (ctx.framework or {}).get(SAAS_GATEWAY_KEY)
100
+ if (
101
+ handle is not None
102
+ and callable(getattr(handle, "discover", None))
103
+ and callable(getattr(handle, "execute", None))
104
+ ):
105
+ return handle # type: ignore[return-value] — structural check above
106
+ return None
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Parameters (dict-literal JSON Schema)
111
+ # ---------------------------------------------------------------------------
112
+
113
+ _SAAS_PARAMS: Schema = {
114
+ "type": "object",
115
+ "properties": {
116
+ "action": {
117
+ "type": "string",
118
+ "enum": ["discover", "execute"],
119
+ "description": (
120
+ "`discover` lists available remote tool slugs; `execute` runs one "
121
+ "slug with arguments."
122
+ ),
123
+ },
124
+ "slug": {
125
+ "type": "string",
126
+ "description": (
127
+ "Vendor tool slug to run (e.g. GITHUB_CREATE_ISSUE). Required for "
128
+ "`execute`."
129
+ ),
130
+ },
131
+ "arguments": {
132
+ "type": "object",
133
+ "additionalProperties": True,
134
+ "description": "Arguments object for the remote tool, as the connector expects.",
135
+ },
136
+ "toolkits": {
137
+ "type": "array",
138
+ "items": {"type": "string"},
139
+ "description": "On `discover`, restrict results to these toolkit names.",
140
+ },
141
+ "query": {
142
+ "type": "string",
143
+ "description": "On `discover`, free-text filter over available tools.",
144
+ },
145
+ },
146
+ "required": ["action"],
147
+ "additionalProperties": False,
148
+ }
149
+
150
+
151
+ @dataclass(frozen=True, slots=True, kw_only=True)
152
+ class SaasDetails:
153
+ """Structured detail returned alongside the model-facing content."""
154
+
155
+ action: Literal["discover", "execute"]
156
+ ok: bool
157
+ slug: str | None = None
158
+ count: int | None = None
159
+
160
+
161
+ _SAAS_DESCRIPTION = (
162
+ "Discover and run third-party SaaS actions (GitHub, Slack, Gmail, etc.) through the "
163
+ 'connector gateway. Use `action:"discover"` to find the right tool slug, then '
164
+ '`action:"execute"` with that `slug` and an `arguments` object to perform the action. '
165
+ "Tool slugs are vendor-defined and case-sensitive; pass them exactly as listed."
166
+ )
167
+
168
+ _STUB_NOTE = (
169
+ "The SaaS connector gateway is not configured in this environment, so no remote action "
170
+ "was performed. Configure a connector (and authenticate the relevant toolkit) to enable "
171
+ "these actions."
172
+ )
173
+
174
+
175
+ def _field(params: object, key: str) -> object:
176
+ if isinstance(params, Mapping):
177
+ return params.get(key)
178
+ return getattr(params, key, None)
179
+
180
+
181
+ def _stringify(data: object) -> str:
182
+ """``JSON.stringify(data, null, 2)`` with a non-throwing fallback."""
183
+ try:
184
+ return json.dumps(data, indent=2, ensure_ascii=False, default=str)
185
+ except Exception:
186
+ return str(data)
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Capability builder
191
+ # ---------------------------------------------------------------------------
192
+
193
+
194
+ class _SaasCapability:
195
+ """The live SaaS-action capability — structurally an ``AgentTool``."""
196
+
197
+ name = "saas-action"
198
+ label = "SaaS action"
199
+ description = _SAAS_DESCRIPTION
200
+ parameters: Schema = _SAAS_PARAMS
201
+
202
+ def __init__(self, gateway: SaasGatewayPort | None) -> None:
203
+ self._gateway = gateway
204
+
205
+ async def execute(
206
+ self,
207
+ tool_call_id: str,
208
+ params: object,
209
+ signal: object = None,
210
+ on_update: object = None,
211
+ ) -> AgentToolResult:
212
+ del tool_call_id, signal, on_update
213
+ action_raw = _field(params, "action")
214
+ action: Literal["discover", "execute"] = (
215
+ "discover" if action_raw == "discover" else "execute"
216
+ )
217
+ if self._gateway is None:
218
+ return AgentToolResult(
219
+ content=(TextContent(text=_STUB_NOTE),),
220
+ details=SaasDetails(action=action, ok=False),
221
+ isError=True,
222
+ )
223
+ if action_raw == "discover":
224
+ tools = await self._gateway.discover(
225
+ {"toolkits": _field(params, "toolkits"), "query": _field(params, "query")}
226
+ )
227
+ text = (
228
+ "No remote tools matched."
229
+ if not tools
230
+ else "\n".join(
231
+ f"- {t.slug}{f' — {t.description}' if t.description else ''}"
232
+ for t in tools
233
+ )
234
+ )
235
+ return AgentToolResult(
236
+ content=(TextContent(text=text),),
237
+ details=SaasDetails(action="discover", ok=True, count=len(tools)),
238
+ )
239
+ slug = _field(params, "slug")
240
+ if not isinstance(slug, str) or slug == "":
241
+ return AgentToolResult(
242
+ content=(
243
+ TextContent(text="`slug` is required to execute a SaaS action."),
244
+ ),
245
+ details=SaasDetails(action="execute", ok=False),
246
+ isError=True,
247
+ )
248
+ arguments = _field(params, "arguments")
249
+ args: Mapping[str, object] = (
250
+ arguments if isinstance(arguments, Mapping) else {}
251
+ )
252
+ result = await self._gateway.execute(slug, args)
253
+ return AgentToolResult(
254
+ content=(TextContent(text=_stringify(result.data)),),
255
+ details=SaasDetails(action="execute", ok=result.ok, slug=slug),
256
+ isError=not result.ok,
257
+ )
258
+
259
+
260
+ def build_saas_capability(ctx: DeckContext) -> _SaasCapability:
261
+ """Build the SaaS-action capability, adapting an injected gateway when
262
+ present.
263
+
264
+ :param ctx: the deck context; an optional gateway is read from
265
+ ``ctx.framework[SAAS_GATEWAY_KEY]``
266
+ """
267
+ return _SaasCapability(_read_gateway(ctx))
268
+
269
+
270
+ def _build(ctx: DeckContext) -> Capability:
271
+ return build_saas_capability(ctx)
272
+
273
+
274
+ #: Catalog row for the SaaS-action capability.
275
+ saas_card = CapabilityCard(
276
+ id=capability_id("saas-action"),
277
+ title="SaaS action",
278
+ summary="Discover and run third-party SaaS actions through the connector gateway.",
279
+ build=_build,
280
+ )