swytchcode-runtime 1.1.1__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.
Files changed (31) hide show
  1. {swytchcode_runtime-1.1.1/swytchcode_runtime.egg-info → swytchcode_runtime-1.1.3}/PKG-INFO +31 -19
  2. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/README.md +30 -18
  3. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/pyproject.toml +1 -1
  4. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/client.py +26 -1
  5. swytchcode_runtime-1.1.3/swytchcode_runtime/schema.py +212 -0
  6. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3/swytchcode_runtime.egg-info}/PKG-INFO +31 -19
  7. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime.egg-info/SOURCES.txt +1 -0
  8. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/tests/test_alias.py +5 -2
  9. swytchcode_runtime-1.1.3/tests/test_nested_body.py +203 -0
  10. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/tests/test_prompts.py +3 -1
  11. swytchcode_runtime-1.1.1/swytchcode_runtime/schema.py +0 -114
  12. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/LICENSE.md +0 -0
  13. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/setup.cfg +0 -0
  14. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/__init__.py +0 -0
  15. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/cli.py +0 -0
  16. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/discover.py +0 -0
  17. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/errors.py +0 -0
  18. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/exec.py +0 -0
  19. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/manage.py +0 -0
  20. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/prompts.py +0 -0
  21. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/__init__.py +0 -0
  22. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/anthropic.py +0 -0
  23. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/base.py +0 -0
  24. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/crewai.py +0 -0
  25. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/langgraph.py +0 -0
  26. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/openai_agents.py +0 -0
  27. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime/providers/vercel.py +0 -0
  28. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime.egg-info/dependency_links.txt +0 -0
  29. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/swytchcode_runtime.egg-info/top_level.txt +0 -0
  30. {swytchcode_runtime-1.1.1 → swytchcode_runtime-1.1.3}/tests/test_exec_errors.py +0 -0
  31. {swytchcode_runtime-1.1.1 → 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.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. Log in (opens a browser; creates your Swytchcode session)
188
- swytchcode login
189
-
190
- # 4. Fetch the GitHub integration
187
+ # 3. Fetch the GitHub integration
191
188
  swytchcode get github
192
189
 
193
- # 5. Enable the "star a repo" tool - the trust boundary for what this project can call
194
- swytchcode add 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
- # 6. Connect your GitHub account (opens a browser for the OAuth flow)
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
- response = client.messages.create(
238
- model="claude-3-5-sonnet-latest",
239
- max_tokens=1024,
240
- system=system,
241
- tools=tools,
242
- messages=[{"role": "user", "content": "Star the swytchcodehq/swytchcode-examples repo on GitHub for me."}],
243
- )
244
-
245
- # 4. Run any tool calls Claude made and send the results back
246
- results = swx.handle_tool_calls(response)
247
- print(results)
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. Log in (opens a browser; creates your Swytchcode session)
170
- swytchcode login
171
-
172
- # 4. Fetch the GitHub integration
169
+ # 3. Fetch the GitHub integration
173
170
  swytchcode get github
174
171
 
175
- # 5. Enable the "star a repo" tool - the trust boundary for what this project can call
176
- swytchcode add 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
- # 6. Connect your GitHub account (opens a browser for the OAuth flow)
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
- response = client.messages.create(
220
- model="claude-3-5-sonnet-latest",
221
- max_tokens=1024,
222
- system=system,
223
- tools=tools,
224
- messages=[{"role": "user", "content": "Star the swytchcodehq/swytchcode-examples repo on GitHub for me."}],
225
- )
226
-
227
- # 4. Run any tool calls Claude made and send the results back
228
- results = swx.handle_tool_calls(response)
229
- print(results)
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()
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swytchcode-runtime"
7
- version = "1.1.1"
7
+ version = "1.1.3"
8
8
  description = "Thin runtime wrapper around the Swytchcode CLI"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -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
- final_args = dict(args)
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.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. Log in (opens a browser; creates your Swytchcode session)
188
- swytchcode login
189
-
190
- # 4. Fetch the GitHub integration
187
+ # 3. Fetch the GitHub integration
191
188
  swytchcode get github
192
189
 
193
- # 5. Enable the "star a repo" tool - the trust boundary for what this project can call
194
- swytchcode add 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
- # 6. Connect your GitHub account (opens a browser for the OAuth flow)
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
- response = client.messages.create(
238
- model="claude-3-5-sonnet-latest",
239
- max_tokens=1024,
240
- system=system,
241
- tools=tools,
242
- messages=[{"role": "user", "content": "Star the swytchcodehq/swytchcode-examples repo on GitHub for me."}],
243
- )
244
-
245
- # 4. Run any tool calls Claude made and send the results back
246
- results = swx.handle_tool_calls(response)
247
- print(results)
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()
@@ -23,5 +23,6 @@ swytchcode_runtime/providers/openai_agents.py
23
23
  swytchcode_runtime/providers/vercel.py
24
24
  tests/test_alias.py
25
25
  tests/test_exec_errors.py
26
+ tests/test_nested_body.py
26
27
  tests/test_prompts.py
27
28
  tests/test_schema_routing.py
@@ -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
- self.assertTrue(re.search(r"_[0-9a-f]{6}$", alias2) or re.search(r"_[0-9a-f]{6}$", alias1))
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(TOOL_USE_INSTRUCTIONS, re.compile("call the matching tool", re.I))
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)