Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions .cursor/skills/sdk-migration/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
---
name: sdk-migration
description: >-
Migration guide for breaking changes between subconscious-sdk versions.
Use when upgrading the SDK, fixing import errors after upgrade, or when
the user encounters removed types like ModelUsage or PlatformToolUsage.
---

# SDK Migration Guide

## Migrating from 0.x to 1.0

Version 1.0 aligns the Python SDK's response types with the API wire format.
Response models are now Pydantic `BaseModel` instances instead of dataclasses,
and their fields match the JSON returned by the Subconscious API 1:1.

### Summary of breaking changes

- `Usage` is now a flat Pydantic model (`input_tokens`, `output_tokens`, `duration_ms`). The old `models` and `platform_tools` fields are removed.
- `ModelUsage` and `PlatformToolUsage` are deleted entirely.
- `ReasoningNode` is removed. Use `ReasoningTask` instead.
- `ReasoningTask.subtask` is renamed to `subtasks`.
- `ReasoningTask.tooluse` is now `Optional[AgentToolUse]` instead of `List[Any]`.
- `RunResult.reasoning` is now `Optional[List[ReasoningTask]]` instead of `Optional[ReasoningNode]`.
- `Run` has a new `error: Optional[RunError]` field for failed runs.
- `Run`, `RunResult`, `Usage`, `RunError`, `ReasoningTask`, and `AgentToolUse` are Pydantic `BaseModel` instead of `@dataclass`.
- `Engine` literal expanded: added `"tim-edge"`, `"tim-oss-local"`, `"tim-1.5"`, `"tim-gpt-heavy-tc"`.

### Usage — before and after

```python
# 0.x — broken; models/platform_tools were raw dicts, not dataclass instances
run.usage.models[0].input_tokens
run.usage.platform_tools[0].tool_id

# 1.0 — flat, matches API wire format
run.usage.input_tokens # int
run.usage.output_tokens # int
run.usage.duration_ms # Optional[int]
```

### ReasoningTask — before and after

```python
# 0.x
from subconscious import ReasoningNode
node.subtask # List[ReasoningNode]
node.tooluse # List[Any]

# 1.0 — ReasoningNode is removed
from subconscious import ReasoningTask
task.subtasks # Optional[List[ReasoningTask]]
task.tooluse # Optional[AgentToolUse]
task.tooluse.tool_name # str
task.tooluse.tool_call_id # Optional[str]
task.tooluse.parameters # Dict[str, Any]
task.tooluse.tool_result # Optional[Any]
```

### RunResult.reasoning — before and after

```python
# 0.x — single node
run.result.reasoning # Optional[ReasoningNode]

# 1.0 — list of tasks (matches API)
run.result.reasoning # Optional[List[ReasoningTask]]
for task in run.result.reasoning or []:
print(task.title, task.thought)
```

### Run.error — new field

```python
# 1.0 — error details on failed runs
if run.status == "failed" and run.error:
print(f"Error {run.error.code}: {run.error.message}")
```

### Removed exports

```python
# 0.x — these imports will fail in 1.0
from subconscious import ModelUsage # REMOVED
from subconscious import PlatformToolUsage # REMOVED
from subconscious import ReasoningNode # REMOVED

# 1.0 — new exports
from subconscious import AgentToolUse
from subconscious import RunError
from subconscious import ReasoningTask
```

### Dataclass to Pydantic

Attribute access is unchanged (`run.run_id`, `run.usage.input_tokens`), but
`isinstance` checks and construction patterns differ:

```python
# 0.x
from dataclasses import is_dataclass
assert is_dataclass(run)

# 1.0
from pydantic import BaseModel
assert isinstance(run, BaseModel)

# Construction still works the same way:
run = Run(run_id="run_abc", status="queued")
```

### Engine literal

```python
# 0.x
Engine = Literal["tim", "tim-claude", "tim-claude-heavy"]

# 1.0
Engine = Literal[
"tim", "tim-edge", "tim-claude", "tim-claude-heavy",
"tim-oss-local", "tim-1.5", "tim-gpt-heavy-tc",
]
```

### Quick find-and-replace patterns

| Old pattern | Replacement |
|---|---|
| `from subconscious import ModelUsage` | Remove — no longer exists |
| `from subconscious import PlatformToolUsage` | Remove — no longer exists |
| `from subconscious import ReasoningNode` | `from subconscious import ReasoningTask` |
| `run.usage.models` | `run.usage.input_tokens` / `run.usage.output_tokens` |
| `run.usage.platform_tools` | Remove — no longer exists |
| `.subtask` (on ReasoningTask) | `.subtasks` |
| `ReasoningNode` (in type hints/code) | `ReasoningTask` |
| `run.result.reasoning.title` | `run.result.reasoning[0].title` (now a list) |
| `RunInput(..., reasoning_format=...)` | Remove — field no longer exists |
| `{'reasoningFormat': ...}` | Remove — field no longer exists |

## `reasoning_format` removed

The `reasoning_format` input field (aliased as `reasoningFormat` on the wire) is gone. If you were shaping the reasoning trace with a JSON/Pydantic schema, fold that guidance into your `instructions` or into `answer_format` instead — the agent's final output is the contract, and the reasoning trace is best treated as a read-only byproduct.

```python
# Before
client.run(
engine='tim',
input={
'instructions': '...',
'answerFormat': AnswerSchema,
'reasoningFormat': ReasoningSchema,
},
)

# After
client.run(
engine='tim',
input={
'instructions': '...',
'answerFormat': AnswerSchema,
},
)
```

Requests that still include `reasoningFormat` are rejected by the API.

## `RunResult.parsed_answer`

`result.answer` is always a `str` on the wire, even when `answerFormat` is supplied — the API JSON-encodes the structured value. The SDK now attaches a `parsed_answer` companion field on every response that runs through the client (`run`, `get`, `wait`, `cancel`), populated via a best-effort `json.loads` of `answer`.

```python
from pydantic import BaseModel

class Person(BaseModel):
name: str
age: int

run = client.run(
engine='tim',
input={
'instructions': 'return JSON for a person',
'answerFormat': Person,
},
options={'await_completion': True},
)

run.result.answer # '{"name":"ada","age":36}' (raw string)
run.result.parsed_answer # {'name': 'ada', 'age': 36} (decoded dict)
```

`parsed_answer` is typed as `Any` — validate with your Pydantic model of choice (`Person.model_validate(run.result.parsed_answer)`) if you want a typed instance. It is `None` when `answer` is empty or not valid JSON.
27 changes: 22 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
name: Tests
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
push:
branches:
- main

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- uses: astral-sh/ruff-action@v3
with:
args: check

- uses: astral-sh/ruff-action@v3
with:
args: format --check

test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4

Expand Down
7 changes: 7 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.0
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
7 changes: 7 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Migration Guide

For the full migration guide with before/after code examples, see the
[SDK Migration Skill](.cursor/skills/sdk-migration/SKILL.md).

This file is also available on GitHub:
https://github.com/subconscious-systems/subconscious-python/blob/main/.cursor/skills/sdk-migration/SKILL.md
Loading
Loading