swytchcode-runtime 1.1.2__tar.gz → 1.1.3__tar.gz
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.
- {swytchcode_runtime-1.1.2/swytchcode_runtime.egg-info → swytchcode_runtime-1.1.3}/PKG-INFO +31 -19
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/README.md +30 -18
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/pyproject.toml +1 -1
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/client.py +26 -1
- swytchcode_runtime-1.1.3/swytchcode_runtime/schema.py +212 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3/swytchcode_runtime.egg-info}/PKG-INFO +31 -19
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime.egg-info/SOURCES.txt +1 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/tests/test_alias.py +5 -2
- swytchcode_runtime-1.1.3/tests/test_nested_body.py +203 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/tests/test_prompts.py +3 -1
- swytchcode_runtime-1.1.2/swytchcode_runtime/schema.py +0 -114
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/LICENSE.md +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/setup.cfg +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/__init__.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/cli.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/discover.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/errors.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/exec.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/manage.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/prompts.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/__init__.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/anthropic.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/base.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/crewai.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/langgraph.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/openai_agents.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/vercel.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime.egg-info/dependency_links.txt +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime.egg-info/top_level.txt +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/tests/test_exec_errors.py +0 -0
- {swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/tests/test_schema_routing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swytchcode-runtime
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.3
|
|
4
4
|
Summary: Thin runtime wrapper around the Swytchcode CLI
|
|
5
5
|
Author: Swytchcode
|
|
6
6
|
License: MIT
|
|
@@ -184,16 +184,13 @@ curl -fsSL https://cli.swytchcode.com/install.sh | sh
|
|
|
184
184
|
# 2. Scaffold .swytchcode/ + tooling.json in your project
|
|
185
185
|
swytchcode init
|
|
186
186
|
|
|
187
|
-
# 3.
|
|
188
|
-
swytchcode login
|
|
189
|
-
|
|
190
|
-
# 4. Fetch the GitHub integration
|
|
187
|
+
# 3. Fetch the GitHub integration
|
|
191
188
|
swytchcode get github
|
|
192
189
|
|
|
193
|
-
#
|
|
194
|
-
swytchcode add github.user.starred.update
|
|
190
|
+
# 4. Enable the "star a repo" tool - the trust boundary for what this project can call
|
|
191
|
+
swytchcode add method github.user.starred.update
|
|
195
192
|
|
|
196
|
-
#
|
|
193
|
+
# 5. Connect your GitHub account (opens a browser for the OAuth flow)
|
|
197
194
|
swytchcode auth connect github
|
|
198
195
|
```
|
|
199
196
|
|
|
@@ -234,17 +231,32 @@ def run_agent():
|
|
|
234
231
|
# just describing what it would do
|
|
235
232
|
system = f"You are a helpful assistant.\n\n{TOOL_USE_INSTRUCTIONS}"
|
|
236
233
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
234
|
+
messages = [{"role": "user", "content": "Star the swytchcodehq/swytchcode-examples repo on GitHub for me."}]
|
|
235
|
+
|
|
236
|
+
# 4. Loop until Claude stops requesting tool calls: run any tool calls
|
|
237
|
+
# Claude made and send the results back so it can keep working toward
|
|
238
|
+
# a final natural-language reply instead of stopping after one round
|
|
239
|
+
while True:
|
|
240
|
+
response = client.messages.create(
|
|
241
|
+
model="claude-sonnet-5",
|
|
242
|
+
max_tokens=1024,
|
|
243
|
+
system=system,
|
|
244
|
+
tools=tools,
|
|
245
|
+
messages=messages,
|
|
246
|
+
)
|
|
247
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
248
|
+
|
|
249
|
+
if response.stop_reason == "max_tokens":
|
|
250
|
+
raise RuntimeError("Response truncated at max_tokens - increase the limit and retry")
|
|
251
|
+
if response.stop_reason != "tool_use":
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
tool_results = swx.handle_tool_calls(response)
|
|
255
|
+
messages.append({"role": "user", "content": tool_results})
|
|
256
|
+
|
|
257
|
+
for block in response.content:
|
|
258
|
+
if block.type == "text":
|
|
259
|
+
print(block.text)
|
|
248
260
|
|
|
249
261
|
if __name__ == "__main__":
|
|
250
262
|
run_agent()
|
|
@@ -166,16 +166,13 @@ curl -fsSL https://cli.swytchcode.com/install.sh | sh
|
|
|
166
166
|
# 2. Scaffold .swytchcode/ + tooling.json in your project
|
|
167
167
|
swytchcode init
|
|
168
168
|
|
|
169
|
-
# 3.
|
|
170
|
-
swytchcode login
|
|
171
|
-
|
|
172
|
-
# 4. Fetch the GitHub integration
|
|
169
|
+
# 3. Fetch the GitHub integration
|
|
173
170
|
swytchcode get github
|
|
174
171
|
|
|
175
|
-
#
|
|
176
|
-
swytchcode add github.user.starred.update
|
|
172
|
+
# 4. Enable the "star a repo" tool - the trust boundary for what this project can call
|
|
173
|
+
swytchcode add method github.user.starred.update
|
|
177
174
|
|
|
178
|
-
#
|
|
175
|
+
# 5. Connect your GitHub account (opens a browser for the OAuth flow)
|
|
179
176
|
swytchcode auth connect github
|
|
180
177
|
```
|
|
181
178
|
|
|
@@ -216,17 +213,32 @@ def run_agent():
|
|
|
216
213
|
# just describing what it would do
|
|
217
214
|
system = f"You are a helpful assistant.\n\n{TOOL_USE_INSTRUCTIONS}"
|
|
218
215
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
216
|
+
messages = [{"role": "user", "content": "Star the swytchcodehq/swytchcode-examples repo on GitHub for me."}]
|
|
217
|
+
|
|
218
|
+
# 4. Loop until Claude stops requesting tool calls: run any tool calls
|
|
219
|
+
# Claude made and send the results back so it can keep working toward
|
|
220
|
+
# a final natural-language reply instead of stopping after one round
|
|
221
|
+
while True:
|
|
222
|
+
response = client.messages.create(
|
|
223
|
+
model="claude-sonnet-5",
|
|
224
|
+
max_tokens=1024,
|
|
225
|
+
system=system,
|
|
226
|
+
tools=tools,
|
|
227
|
+
messages=messages,
|
|
228
|
+
)
|
|
229
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
230
|
+
|
|
231
|
+
if response.stop_reason == "max_tokens":
|
|
232
|
+
raise RuntimeError("Response truncated at max_tokens - increase the limit and retry")
|
|
233
|
+
if response.stop_reason != "tool_use":
|
|
234
|
+
break
|
|
235
|
+
|
|
236
|
+
tool_results = swx.handle_tool_calls(response)
|
|
237
|
+
messages.append({"role": "user", "content": tool_results})
|
|
238
|
+
|
|
239
|
+
for block in response.content:
|
|
240
|
+
if block.type == "text":
|
|
241
|
+
print(block.text)
|
|
230
242
|
|
|
231
243
|
if __name__ == "__main__":
|
|
232
244
|
run_agent()
|
|
@@ -51,6 +51,29 @@ def _toolkit_matches(toolkit: str, integration: str) -> bool:
|
|
|
51
51
|
return tk == project or tk == lib_slug or tk == prefix.lower()
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _to_plain(obj: Any) -> Any:
|
|
55
|
+
"""Recursively convert Pydantic models into plain JSON-serializable values.
|
|
56
|
+
|
|
57
|
+
LangGraph and CrewAI validate a tool call's arguments against the Pydantic
|
|
58
|
+
args_schema, so a nested-object field such as `body` reaches execute() as a
|
|
59
|
+
model instance rather than a dict. json.dumps can't serialize that, which is
|
|
60
|
+
what raised "Object of type <tool>Schema_body is not JSON serializable".
|
|
61
|
+
Flattening to dicts here also lets _strip_empty run over the body, which it
|
|
62
|
+
skips for anything that isn't a dict or list.
|
|
63
|
+
"""
|
|
64
|
+
dump = getattr(obj, "model_dump", None) # pydantic v2
|
|
65
|
+
if callable(dump):
|
|
66
|
+
return dump(mode="json")
|
|
67
|
+
legacy = getattr(obj, "dict", None) # pydantic v1
|
|
68
|
+
if callable(legacy) and hasattr(obj, "__fields__"):
|
|
69
|
+
return _to_plain(legacy())
|
|
70
|
+
if isinstance(obj, dict):
|
|
71
|
+
return {k: _to_plain(v) for k, v in obj.items()}
|
|
72
|
+
if isinstance(obj, (list, tuple)):
|
|
73
|
+
return [_to_plain(v) for v in obj]
|
|
74
|
+
return obj
|
|
75
|
+
|
|
76
|
+
|
|
54
77
|
def _strip_empty(obj: Any) -> Any:
|
|
55
78
|
"""Recursively drop keys whose value is None or an empty string ("").
|
|
56
79
|
|
|
@@ -126,7 +149,9 @@ class _Tools:
|
|
|
126
149
|
return p.format_tools(neutral) if p else neutral
|
|
127
150
|
|
|
128
151
|
def execute(self, canonical_id: str, args: dict, **options) -> Any:
|
|
129
|
-
|
|
152
|
+
# Flatten any Pydantic models (from agentic providers' arg validation)
|
|
153
|
+
# to plain values before routing/serialization.
|
|
154
|
+
final_args = _to_plain(dict(args))
|
|
130
155
|
|
|
131
156
|
# If args are flat (no body/params top-level keys), wrap them in body
|
|
132
157
|
# as expected by the Swytchcode CLI kernel (like in run-workflow.js)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Simplify a tool's input schema for the LLM: expose all fields, mark required ones."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
# Map wrekenfile/CLI type names onto JSON Schema types. Lowercase keys already in
|
|
8
|
+
# JSON Schema form (e.g. "string", "integer") map to themselves so a nested
|
|
9
|
+
# `schema` block from `swytchcode info` passes through unchanged.
|
|
10
|
+
_TYPE_MAP = {
|
|
11
|
+
"int": "integer",
|
|
12
|
+
"integer": "integer",
|
|
13
|
+
"float": "number",
|
|
14
|
+
"number": "number",
|
|
15
|
+
"double": "number",
|
|
16
|
+
"bool": "boolean",
|
|
17
|
+
"boolean": "boolean",
|
|
18
|
+
"object": "object",
|
|
19
|
+
"any": "object",
|
|
20
|
+
"string": "string",
|
|
21
|
+
"array": "array",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _json_type(raw: Any) -> str:
|
|
26
|
+
t = str(raw or "string").strip().lower()
|
|
27
|
+
if t.startswith("[]"):
|
|
28
|
+
return "array"
|
|
29
|
+
if t.startswith(("struct(", "map(")):
|
|
30
|
+
return "object"
|
|
31
|
+
return _TYPE_MAP.get(t, "string")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_valid_name(name: str) -> bool:
|
|
35
|
+
"""Return True if name is a valid tool parameter identifier.
|
|
36
|
+
|
|
37
|
+
Anthropic and OpenAI require parameter names to match ^[a-zA-Z0-9_-]+$.
|
|
38
|
+
Google API schemas include system parameters like $.xgafv which cause
|
|
39
|
+
Anthropic API 400 Bad Request errors if included in tool schemas.
|
|
40
|
+
"""
|
|
41
|
+
if not name or name.startswith("$"):
|
|
42
|
+
return False
|
|
43
|
+
return all(c.isalnum() or c in ("_", "-") for c in name)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _nested(spec: dict) -> dict | None:
|
|
47
|
+
"""Locate the nested object schema on a field spec.
|
|
48
|
+
|
|
49
|
+
`swytchcode info` nests an object body's fields under a `schema` key
|
|
50
|
+
(``{"TYPE": "OBJECT", "schema": {"properties": {...}, "required": [...]}}``),
|
|
51
|
+
while a plain JSON Schema keeps `properties` inline. Handle both.
|
|
52
|
+
"""
|
|
53
|
+
inner = spec.get("schema")
|
|
54
|
+
if isinstance(inner, dict) and isinstance(inner.get("properties"), dict):
|
|
55
|
+
return inner
|
|
56
|
+
if isinstance(spec.get("properties"), dict):
|
|
57
|
+
return spec
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_required(spec: dict) -> bool:
|
|
62
|
+
r = spec.get("required", spec.get("REQUIRED"))
|
|
63
|
+
return r is True or (isinstance(r, str) and r.strip().lower() == "true")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _expand(spec: Any) -> dict:
|
|
67
|
+
"""Convert one field spec into a JSON Schema fragment, recursing into nested
|
|
68
|
+
object properties and array items so the model sees the full shape."""
|
|
69
|
+
if not isinstance(spec, dict):
|
|
70
|
+
return {"type": "string"}
|
|
71
|
+
|
|
72
|
+
t = _json_type(spec.get("TYPE", spec.get("type", "string")))
|
|
73
|
+
out: dict = {"type": t}
|
|
74
|
+
|
|
75
|
+
desc = spec.get("DESC", spec.get("description"))
|
|
76
|
+
if desc:
|
|
77
|
+
out["description"] = desc
|
|
78
|
+
|
|
79
|
+
if t == "object":
|
|
80
|
+
nested = _nested(spec)
|
|
81
|
+
if nested is not None:
|
|
82
|
+
props = nested["properties"]
|
|
83
|
+
out["properties"] = {
|
|
84
|
+
name: _expand(child)
|
|
85
|
+
for name, child in props.items()
|
|
86
|
+
if _is_valid_name(name)
|
|
87
|
+
}
|
|
88
|
+
explicit = nested.get("required")
|
|
89
|
+
required = list(explicit) if isinstance(explicit, list) else []
|
|
90
|
+
for name, child in props.items():
|
|
91
|
+
if (
|
|
92
|
+
_is_valid_name(name)
|
|
93
|
+
and isinstance(child, dict)
|
|
94
|
+
and _is_required(child)
|
|
95
|
+
and name not in required
|
|
96
|
+
):
|
|
97
|
+
required.append(name)
|
|
98
|
+
out["required"] = [name for name in required if name in out["properties"]]
|
|
99
|
+
elif t == "array":
|
|
100
|
+
items = spec.get("items")
|
|
101
|
+
if items is None and isinstance(spec.get("schema"), dict):
|
|
102
|
+
items = spec["schema"].get("items")
|
|
103
|
+
if isinstance(items, dict):
|
|
104
|
+
out["items"] = _expand(items)
|
|
105
|
+
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def simplify(inputs: Any) -> dict:
|
|
110
|
+
# Handle Wrekenfile shape: a list of single-key dicts (e.g. [{"amount": {"TYPE": "INT"...}}])
|
|
111
|
+
if isinstance(inputs, list):
|
|
112
|
+
props = {}
|
|
113
|
+
required = []
|
|
114
|
+
for item in inputs:
|
|
115
|
+
if not isinstance(item, dict):
|
|
116
|
+
continue
|
|
117
|
+
for name, spec in item.items():
|
|
118
|
+
if not isinstance(spec, dict) or not _is_valid_name(name):
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
# Expand into full JSON Schema, keeping nested object/array shape
|
|
122
|
+
# (a body's fields live under spec["schema"] and were previously
|
|
123
|
+
# dropped, leaving the model blind to what to send).
|
|
124
|
+
props[name] = _expand(spec)
|
|
125
|
+
|
|
126
|
+
req = spec.get("REQUIRED", False)
|
|
127
|
+
loc = str(spec.get("LOCATION", spec.get("location", ""))).lower()
|
|
128
|
+
is_required = (
|
|
129
|
+
loc == "path"
|
|
130
|
+
or req is True
|
|
131
|
+
or (isinstance(req, str) and req.strip().lower() == "true")
|
|
132
|
+
)
|
|
133
|
+
if is_required:
|
|
134
|
+
required.append(name)
|
|
135
|
+
|
|
136
|
+
# rule: expose ALL fields to the model and list only the
|
|
137
|
+
# truly-required ones in `required`. A required-only approach hid optional
|
|
138
|
+
# fields - which left all-optional tools (e.g. Stripe) with an empty schema
|
|
139
|
+
# so the model called them with no arguments, and blinded the model to
|
|
140
|
+
# optional fields on tools that do have some required ones.
|
|
141
|
+
return {"type": "object", "properties": props, "required": required}
|
|
142
|
+
|
|
143
|
+
# Fallback to standard JSON Schema handling
|
|
144
|
+
if not isinstance(inputs, dict):
|
|
145
|
+
return {"type": "object", "properties": {}, "required": []}
|
|
146
|
+
|
|
147
|
+
props = inputs.get("properties") or {}
|
|
148
|
+
required = inputs.get("required") or []
|
|
149
|
+
keep = {}
|
|
150
|
+
|
|
151
|
+
# Expose all fields (same rule as the wrekenfile branch above); use the
|
|
152
|
+
# original required list only for the `required` key so optional/nested
|
|
153
|
+
# fields stay optional instead of being dropped or forced required.
|
|
154
|
+
for name, spec in props.items():
|
|
155
|
+
if not _is_valid_name(name):
|
|
156
|
+
continue
|
|
157
|
+
if isinstance(spec, dict):
|
|
158
|
+
loc = str(spec.get("LOCATION", spec.get("location", ""))).lower()
|
|
159
|
+
if loc == "path" and name not in required:
|
|
160
|
+
required.append(name)
|
|
161
|
+
|
|
162
|
+
if _json_type(spec.get("type", spec.get("TYPE"))) in ("object", "array"):
|
|
163
|
+
spec = _expand(spec) # expand nested objects/arrays
|
|
164
|
+
keep[name] = spec
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
"type": "object",
|
|
168
|
+
"properties": keep,
|
|
169
|
+
"required": [r for r in required if r in keep],
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def to_pydantic_model(schema: dict, name: str = "ArgsSchema") -> Any:
|
|
174
|
+
"""Dynamically build a Pydantic model from a JSON schema for LangGraph args_schema."""
|
|
175
|
+
from pydantic import create_model
|
|
176
|
+
|
|
177
|
+
if not schema or "properties" not in schema:
|
|
178
|
+
return create_model(name)
|
|
179
|
+
|
|
180
|
+
fields = {}
|
|
181
|
+
properties = schema.get("properties", {})
|
|
182
|
+
required = set(schema.get("required", []))
|
|
183
|
+
|
|
184
|
+
for field_name, field_info in properties.items():
|
|
185
|
+
field_type = str
|
|
186
|
+
t = field_info.get("type")
|
|
187
|
+
if t == "integer":
|
|
188
|
+
field_type = int
|
|
189
|
+
elif t == "number":
|
|
190
|
+
field_type = float
|
|
191
|
+
elif t == "boolean":
|
|
192
|
+
field_type = bool
|
|
193
|
+
elif t == "array":
|
|
194
|
+
field_type = list
|
|
195
|
+
elif t == "object":
|
|
196
|
+
# Only build a nested model when the object's fields are known.
|
|
197
|
+
# A property-less object (freeform body) becomes a plain dict: an
|
|
198
|
+
# empty model would silently drop every value the agent passed and
|
|
199
|
+
# then fail to JSON-serialize.
|
|
200
|
+
if field_info.get("properties"):
|
|
201
|
+
field_type = to_pydantic_model(field_info, f"{name}_{field_name}")
|
|
202
|
+
else:
|
|
203
|
+
field_type = dict
|
|
204
|
+
|
|
205
|
+
if field_name in required:
|
|
206
|
+
fields[field_name] = (field_type, ...)
|
|
207
|
+
else:
|
|
208
|
+
# `X | None` so an explicit null is accepted (pydantic v2 rejects
|
|
209
|
+
# None for a bare non-optional annotation).
|
|
210
|
+
fields[field_name] = (field_type | None, None)
|
|
211
|
+
|
|
212
|
+
return create_model(name, **fields)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swytchcode-runtime
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.3
|
|
4
4
|
Summary: Thin runtime wrapper around the Swytchcode CLI
|
|
5
5
|
Author: Swytchcode
|
|
6
6
|
License: MIT
|
|
@@ -184,16 +184,13 @@ curl -fsSL https://cli.swytchcode.com/install.sh | sh
|
|
|
184
184
|
# 2. Scaffold .swytchcode/ + tooling.json in your project
|
|
185
185
|
swytchcode init
|
|
186
186
|
|
|
187
|
-
# 3.
|
|
188
|
-
swytchcode login
|
|
189
|
-
|
|
190
|
-
# 4. Fetch the GitHub integration
|
|
187
|
+
# 3. Fetch the GitHub integration
|
|
191
188
|
swytchcode get github
|
|
192
189
|
|
|
193
|
-
#
|
|
194
|
-
swytchcode add github.user.starred.update
|
|
190
|
+
# 4. Enable the "star a repo" tool - the trust boundary for what this project can call
|
|
191
|
+
swytchcode add method github.user.starred.update
|
|
195
192
|
|
|
196
|
-
#
|
|
193
|
+
# 5. Connect your GitHub account (opens a browser for the OAuth flow)
|
|
197
194
|
swytchcode auth connect github
|
|
198
195
|
```
|
|
199
196
|
|
|
@@ -234,17 +231,32 @@ def run_agent():
|
|
|
234
231
|
# just describing what it would do
|
|
235
232
|
system = f"You are a helpful assistant.\n\n{TOOL_USE_INSTRUCTIONS}"
|
|
236
233
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
234
|
+
messages = [{"role": "user", "content": "Star the swytchcodehq/swytchcode-examples repo on GitHub for me."}]
|
|
235
|
+
|
|
236
|
+
# 4. Loop until Claude stops requesting tool calls: run any tool calls
|
|
237
|
+
# Claude made and send the results back so it can keep working toward
|
|
238
|
+
# a final natural-language reply instead of stopping after one round
|
|
239
|
+
while True:
|
|
240
|
+
response = client.messages.create(
|
|
241
|
+
model="claude-sonnet-5",
|
|
242
|
+
max_tokens=1024,
|
|
243
|
+
system=system,
|
|
244
|
+
tools=tools,
|
|
245
|
+
messages=messages,
|
|
246
|
+
)
|
|
247
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
248
|
+
|
|
249
|
+
if response.stop_reason == "max_tokens":
|
|
250
|
+
raise RuntimeError("Response truncated at max_tokens - increase the limit and retry")
|
|
251
|
+
if response.stop_reason != "tool_use":
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
tool_results = swx.handle_tool_calls(response)
|
|
255
|
+
messages.append({"role": "user", "content": tool_results})
|
|
256
|
+
|
|
257
|
+
for block in response.content:
|
|
258
|
+
if block.type == "text":
|
|
259
|
+
print(block.text)
|
|
248
260
|
|
|
249
261
|
if __name__ == "__main__":
|
|
250
262
|
run_agent()
|
|
@@ -33,9 +33,12 @@ class TestAlias(unittest.TestCase):
|
|
|
33
33
|
alias2 = _make_alias("a_b", taken) # Collides on sanitize
|
|
34
34
|
|
|
35
35
|
self.assertNotEqual(alias1, alias2)
|
|
36
|
-
|
|
36
|
+
|
|
37
37
|
import re
|
|
38
|
-
|
|
38
|
+
|
|
39
|
+
self.assertTrue(
|
|
40
|
+
re.search(r"_[0-9a-f]{6}$", alias2) or re.search(r"_[0-9a-f]{6}$", alias1)
|
|
41
|
+
)
|
|
39
42
|
|
|
40
43
|
|
|
41
44
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
from swytchcode_runtime import client as sc_client
|
|
5
|
+
from swytchcode_runtime.client import _to_plain
|
|
6
|
+
from swytchcode_runtime.schema import simplify, to_pydantic_model
|
|
7
|
+
|
|
8
|
+
# The shape `swytchcode info` returns for a POST tool whose body is an object:
|
|
9
|
+
# the body's fields live under spec["schema"], which simplify() used to drop.
|
|
10
|
+
BODY_INPUTS = [
|
|
11
|
+
{"owner": {"LOCATION": "path", "TYPE": "STRING"}},
|
|
12
|
+
{
|
|
13
|
+
"body": {
|
|
14
|
+
"LOCATION": "body",
|
|
15
|
+
"TYPE": "OBJECT",
|
|
16
|
+
"schema": {
|
|
17
|
+
"properties": {
|
|
18
|
+
"prompt": {"type": "string", "required": True},
|
|
19
|
+
"create_pull_request": {"type": "boolean", "required": False},
|
|
20
|
+
},
|
|
21
|
+
"required": ["prompt"],
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestNestedBodySchema(unittest.TestCase):
|
|
29
|
+
def test_object_body_expands_nested_properties(self):
|
|
30
|
+
body = simplify(BODY_INPUTS)["properties"]["body"]
|
|
31
|
+
self.assertEqual(body["type"], "object")
|
|
32
|
+
self.assertIn("prompt", body["properties"])
|
|
33
|
+
self.assertIn("create_pull_request", body["properties"])
|
|
34
|
+
self.assertEqual(body["properties"]["prompt"]["type"], "string")
|
|
35
|
+
self.assertEqual(body["properties"]["create_pull_request"]["type"], "boolean")
|
|
36
|
+
self.assertEqual(body["required"], ["prompt"])
|
|
37
|
+
|
|
38
|
+
def test_deeply_nested_object_expands(self):
|
|
39
|
+
inputs = [
|
|
40
|
+
{
|
|
41
|
+
"body": {
|
|
42
|
+
"LOCATION": "body",
|
|
43
|
+
"TYPE": "OBJECT",
|
|
44
|
+
"schema": {
|
|
45
|
+
"properties": {
|
|
46
|
+
"start": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"schema": {
|
|
49
|
+
"properties": {
|
|
50
|
+
"dateTime": {"type": "string", "required": True}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
body = simplify(inputs)["properties"]["body"]
|
|
60
|
+
start = body["properties"]["start"]
|
|
61
|
+
self.assertEqual(start["type"], "object")
|
|
62
|
+
self.assertEqual(start["properties"]["dateTime"]["type"], "string")
|
|
63
|
+
self.assertEqual(start["required"], ["dateTime"])
|
|
64
|
+
|
|
65
|
+
def test_array_of_nested_objects_expands(self):
|
|
66
|
+
inputs = [
|
|
67
|
+
{
|
|
68
|
+
"body": {
|
|
69
|
+
"LOCATION": "body",
|
|
70
|
+
"TYPE": "OBJECT",
|
|
71
|
+
"schema": {
|
|
72
|
+
"properties": {
|
|
73
|
+
"attendees": {
|
|
74
|
+
"type": "array",
|
|
75
|
+
"items": {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"schema": {
|
|
78
|
+
"properties": {
|
|
79
|
+
"email": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"required": True,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
body = simplify(inputs)["properties"]["body"]
|
|
93
|
+
attendees = body["properties"]["attendees"]
|
|
94
|
+
self.assertEqual(attendees["type"], "array")
|
|
95
|
+
self.assertEqual(attendees["items"]["type"], "object")
|
|
96
|
+
self.assertEqual(attendees["items"]["properties"]["email"]["type"], "string")
|
|
97
|
+
self.assertEqual(attendees["items"]["required"], ["email"])
|
|
98
|
+
|
|
99
|
+
def test_freeform_object_has_no_properties(self):
|
|
100
|
+
body = simplify([{"body": {"LOCATION": "body", "TYPE": "OBJECT"}}])[
|
|
101
|
+
"properties"
|
|
102
|
+
]["body"]
|
|
103
|
+
self.assertEqual(body, {"type": "object"})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TestPydanticModel(unittest.TestCase):
|
|
107
|
+
def test_object_with_properties_builds_nested_model(self):
|
|
108
|
+
model = to_pydantic_model(simplify(BODY_INPUTS), "T")
|
|
109
|
+
# body is optional, so its annotation is `NestedModel | None`; unwrap it.
|
|
110
|
+
nested = [
|
|
111
|
+
a for a in body_annotation_args(model, "body") if hasattr(a, "model_fields")
|
|
112
|
+
]
|
|
113
|
+
self.assertTrue(nested, "body should be a nested model, not a bare type")
|
|
114
|
+
# The nested body must be a real model, not an empty one, so it can guide
|
|
115
|
+
# and validate the agent's arguments.
|
|
116
|
+
self.assertIn("prompt", nested[0].model_fields)
|
|
117
|
+
|
|
118
|
+
def test_property_less_object_maps_to_dict_not_empty_model(self):
|
|
119
|
+
schema = simplify([{"body": {"LOCATION": "body", "TYPE": "OBJECT"}}])
|
|
120
|
+
model = to_pydantic_model(schema, "T")
|
|
121
|
+
# Optional field annotation is `dict | None`; dict must be one of the args.
|
|
122
|
+
self.assertIn(dict, body_annotation_args(model, "body"))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestToPlain(unittest.TestCase):
|
|
126
|
+
def test_converts_nested_pydantic_model_to_dict(self):
|
|
127
|
+
model_cls = to_pydantic_model(simplify(BODY_INPUTS), "T")
|
|
128
|
+
instance = model_cls(owner="acme", body={"prompt": "hi"})
|
|
129
|
+
plain = _to_plain({"body": instance.body})
|
|
130
|
+
self.assertIsInstance(plain["body"], dict)
|
|
131
|
+
self.assertEqual(plain["body"]["prompt"], "hi")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestExecuteSerializesModels(unittest.TestCase):
|
|
135
|
+
"""The regression guard for the reported crash: a Pydantic body must reach
|
|
136
|
+
the CLI as JSON-serializable data, with empty optionals stripped."""
|
|
137
|
+
|
|
138
|
+
def test_execute_serializes_pydantic_body(self):
|
|
139
|
+
captured = {}
|
|
140
|
+
|
|
141
|
+
def fake_exec(cid, input=None, **kwargs):
|
|
142
|
+
captured["stdin"] = json.dumps(input) # same call exec_ makes
|
|
143
|
+
return {"ok": True}
|
|
144
|
+
|
|
145
|
+
original = sc_client._exec
|
|
146
|
+
sc_client._exec = fake_exec
|
|
147
|
+
try:
|
|
148
|
+
model_cls = to_pydantic_model(simplify(BODY_INPUTS), "T")
|
|
149
|
+
body = model_cls(owner="acme", body={"prompt": "hi"}).body
|
|
150
|
+
client = sc_client.Swytchcode()
|
|
151
|
+
result = client.tools.execute(
|
|
152
|
+
"github.agent.tasks.create",
|
|
153
|
+
{"owner": "acme", "body": body},
|
|
154
|
+
_raw_inputs=BODY_INPUTS,
|
|
155
|
+
)
|
|
156
|
+
finally:
|
|
157
|
+
sc_client._exec = original
|
|
158
|
+
|
|
159
|
+
self.assertEqual(result, {"ok": True})
|
|
160
|
+
sent = json.loads(captured["stdin"])
|
|
161
|
+
self.assertEqual(sent["body"]["prompt"], "hi")
|
|
162
|
+
# create_pull_request was None (unset) and must be stripped, not sent.
|
|
163
|
+
self.assertNotIn("create_pull_request", sent["body"])
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class TestSanitizeParameterNames(unittest.TestCase):
|
|
167
|
+
def test_strips_system_parameters_starting_with_dollar(self):
|
|
168
|
+
inputs = [
|
|
169
|
+
{"$.xgafv": {"LOCATION": "query", "TYPE": "STRING"}},
|
|
170
|
+
{"q": {"LOCATION": "query", "TYPE": "STRING"}},
|
|
171
|
+
]
|
|
172
|
+
res = simplify(inputs)
|
|
173
|
+
self.assertNotIn("$.xgafv", res["properties"])
|
|
174
|
+
self.assertIn("q", res["properties"])
|
|
175
|
+
|
|
176
|
+
def test_strips_dollar_parameters_from_nested_objects(self):
|
|
177
|
+
inputs = [
|
|
178
|
+
{
|
|
179
|
+
"body": {
|
|
180
|
+
"LOCATION": "body",
|
|
181
|
+
"TYPE": "OBJECT",
|
|
182
|
+
"schema": {
|
|
183
|
+
"properties": {
|
|
184
|
+
"valid": {"type": "string"},
|
|
185
|
+
"$.xgafv": {"type": "string"},
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
]
|
|
191
|
+
body = simplify(inputs)["properties"]["body"]
|
|
192
|
+
self.assertIn("valid", body["properties"])
|
|
193
|
+
self.assertNotIn("$.xgafv", body["properties"])
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def body_annotation_args(model, field):
|
|
197
|
+
import typing
|
|
198
|
+
|
|
199
|
+
return typing.get_args(model.model_fields[field].annotation)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
unittest.main()
|
|
@@ -8,7 +8,9 @@ class TestToolUseInstructions(unittest.TestCase):
|
|
|
8
8
|
def test_is_exported_and_instructs_the_model_to_call_tools(self):
|
|
9
9
|
self.assertIsInstance(TOOL_USE_INSTRUCTIONS, str)
|
|
10
10
|
self.assertTrue(len(TOOL_USE_INSTRUCTIONS) > 0)
|
|
11
|
-
self.assertRegex(
|
|
11
|
+
self.assertRegex(
|
|
12
|
+
TOOL_USE_INSTRUCTIONS, re.compile("call the matching tool", re.I)
|
|
13
|
+
)
|
|
12
14
|
|
|
13
15
|
def test_scopes_itself_to_swytchcode_tools_only(self):
|
|
14
16
|
self.assertRegex(
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
"""Simplify a tool's input schema for the LLM: expose all fields, mark required ones."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def simplify(inputs: Any) -> dict:
|
|
9
|
-
# Handle Wrekenfile shape: a list of single-key dicts (e.g. [{"amount": {"TYPE": "INT"...}}])
|
|
10
|
-
if isinstance(inputs, list):
|
|
11
|
-
props = {}
|
|
12
|
-
required = []
|
|
13
|
-
for item in inputs:
|
|
14
|
-
if not isinstance(item, dict):
|
|
15
|
-
continue
|
|
16
|
-
for name, spec in item.items():
|
|
17
|
-
if not isinstance(spec, dict):
|
|
18
|
-
continue
|
|
19
|
-
|
|
20
|
-
# Default to string if TYPE is missing
|
|
21
|
-
t = spec.get("TYPE", "STRING").lower()
|
|
22
|
-
if t == "int":
|
|
23
|
-
t = "integer"
|
|
24
|
-
elif t in ("float", "number", "double"):
|
|
25
|
-
t = "number"
|
|
26
|
-
elif t == "bool":
|
|
27
|
-
t = "boolean"
|
|
28
|
-
elif t == "object" or t == "any":
|
|
29
|
-
t = "object"
|
|
30
|
-
elif t.startswith("[]"):
|
|
31
|
-
t = "array"
|
|
32
|
-
else:
|
|
33
|
-
t = "string"
|
|
34
|
-
|
|
35
|
-
props[name] = {"type": t, "description": spec.get("DESC", "")}
|
|
36
|
-
|
|
37
|
-
req = spec.get("REQUIRED", False)
|
|
38
|
-
loc = str(spec.get("LOCATION", spec.get("location", ""))).lower()
|
|
39
|
-
is_required = (
|
|
40
|
-
loc == "path"
|
|
41
|
-
or req is True
|
|
42
|
-
or (isinstance(req, str) and req.strip().lower() == "true")
|
|
43
|
-
)
|
|
44
|
-
if is_required:
|
|
45
|
-
required.append(name)
|
|
46
|
-
|
|
47
|
-
# rule: expose ALL fields to the model and list only the
|
|
48
|
-
# truly-required ones in `required`. A required-only approach hid optional
|
|
49
|
-
# fields - which left all-optional tools (e.g. Stripe) with an empty schema
|
|
50
|
-
# so the model called them with no arguments, and blinded the model to
|
|
51
|
-
# optional fields on tools that do have some required ones.
|
|
52
|
-
return {"type": "object", "properties": props, "required": required}
|
|
53
|
-
|
|
54
|
-
# Fallback to standard JSON Schema handling
|
|
55
|
-
if not isinstance(inputs, dict):
|
|
56
|
-
return {"type": "object", "properties": {}, "required": []}
|
|
57
|
-
|
|
58
|
-
props = inputs.get("properties") or {}
|
|
59
|
-
required = inputs.get("required") or []
|
|
60
|
-
keep = {}
|
|
61
|
-
|
|
62
|
-
# Expose all fields (same rule as the wrekenfile branch above); use the
|
|
63
|
-
# original required list only for the `required` key so optional/nested
|
|
64
|
-
# fields stay optional instead of being dropped or forced required.
|
|
65
|
-
for name, spec in props.items():
|
|
66
|
-
if isinstance(spec, dict):
|
|
67
|
-
loc = str(spec.get("LOCATION", spec.get("location", ""))).lower()
|
|
68
|
-
if loc == "path" and name not in required:
|
|
69
|
-
required.append(name)
|
|
70
|
-
|
|
71
|
-
if spec.get("type") == "object" and "properties" in spec:
|
|
72
|
-
spec = simplify(spec) # recurse into nested objects
|
|
73
|
-
keep[name] = spec
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
"type": "object",
|
|
77
|
-
"properties": keep,
|
|
78
|
-
"required": [r for r in required if r in keep],
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def to_pydantic_model(schema: dict, name: str = "ArgsSchema") -> Any:
|
|
83
|
-
"""Dynamically build a Pydantic model from a JSON schema for LangGraph args_schema."""
|
|
84
|
-
from pydantic import create_model
|
|
85
|
-
|
|
86
|
-
if not schema or "properties" not in schema:
|
|
87
|
-
return create_model(name)
|
|
88
|
-
|
|
89
|
-
fields = {}
|
|
90
|
-
properties = schema.get("properties", {})
|
|
91
|
-
required = set(schema.get("required", []))
|
|
92
|
-
|
|
93
|
-
for field_name, field_info in properties.items():
|
|
94
|
-
field_type = str
|
|
95
|
-
t = field_info.get("type")
|
|
96
|
-
if t == "integer":
|
|
97
|
-
field_type = int
|
|
98
|
-
elif t == "number":
|
|
99
|
-
field_type = float
|
|
100
|
-
elif t == "boolean":
|
|
101
|
-
field_type = bool
|
|
102
|
-
elif t == "array":
|
|
103
|
-
field_type = list
|
|
104
|
-
elif t == "object":
|
|
105
|
-
field_type = to_pydantic_model(field_info, f"{name}_{field_name}")
|
|
106
|
-
|
|
107
|
-
if field_name in required:
|
|
108
|
-
fields[field_name] = (field_type, ...)
|
|
109
|
-
else:
|
|
110
|
-
# `X | None` so an explicit null is accepted (pydantic v2 rejects
|
|
111
|
-
# None for a bare non-optional annotation).
|
|
112
|
-
fields[field_name] = (field_type | None, None)
|
|
113
|
-
|
|
114
|
-
return create_model(name, **fields)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/__init__.py
RENAMED
|
File without changes
|
{swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/anthropic.py
RENAMED
|
File without changes
|
|
File without changes
|
{swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/crewai.py
RENAMED
|
File without changes
|
{swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/langgraph.py
RENAMED
|
File without changes
|
{swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/openai_agents.py
RENAMED
|
File without changes
|
{swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/vercel.py
RENAMED
|
File without changes
|
|
File without changes
|
{swytchcode_runtime-1.1.2 → swytchcode_runtime-1.1.3}/swytchcode_runtime.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|