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
18 changes: 11 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,14 @@ guarantee. What the SDK models is now that subset and nothing else.

- `is_supported_event()` and `SUPPORTED_EVENTS` — whether an event carries the
guarantee.
- `session:llm_thinking`, `session:tool_status`, `session:required_action` and
`session:restriction`, with models. All four are in the supported scope and
none were modelled before; `session:tool_status` is where an outbound call
reports its number, duration, credits, and textual outcome.
- `session:llm_thinking`, `session:tool_status` and `session:restriction`, with
models. None were modelled before; `session:tool_status` is where an outbound
call reports its number, duration, credits, and textual outcome.
- `AsyncPineAI.rebuild()` — pages through history until the cursor is
exhausted. Recovery is an unconditional rebuild: joining never resumes from a
cursor, and a short or empty page does not mean a range is done.
- `AsyncPineAI.on_reconnect()` — fires after a reconnect has re-joined, so
callers can rebuild. A connection can stay open after delivery has stopped.
- `InputState` with `awaiting_credits` and `needs_phone_verification`. A
blocking condition is read from `session:input_state`, because the events that
elaborate on one are mostly outside the scope.
- `AsyncPineAI.emit_event()` — the escape hatch for sending anything outside the
supported surface.
- Protocol fixtures and contract tests under `tests/protocol`, and
Expand Down Expand Up @@ -68,6 +64,14 @@ untouched — the SDK just no longer models it. Send with `emit_event()`.
- Wall-clock filtering of events older than the moment a turn began. It
contradicts rebuilding from history, and a clock offset made it drop real
events.
- `session:input_state`, `session:required_action` and `session:task_ready`,
with the `InputState`, `InputStateCode`, `RequiredActionData` and
`TaskReadyData` models. The scope no longer covers them. A session stopped on
its credit balance is reported by `session:state`, whose values include
`credits_exhausted` and `task_paused`.
- The turn no longer ends when `session:input_state` reports that input is
accepted. That event is observed to arrive before the agent has said
anything, so ending on it truncates the reply.

## [0.3.3] - 2026-05-23

Expand Down
45 changes: 26 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,7 @@ payloads, and semantics change compatibly or with notice.
| Event | What it is for |
|---|---|
| `session:state` | Where the task stands in its lifecycle |
| `session:input_state` | Whether input is accepted, and the reason when it is not. This is where a blocked session says why |
| `session:message_status` | What became of a message you sent — the only way to tell a rejected or rate-limited one from one still being worked on |
| `session:required_action` | Whether the session is waiting on you |
| `session:update_title` | The session title, as the agent revises it |
| `session:restriction` | An account restriction. The only statement that a task will not complete |

Expand All @@ -88,7 +86,6 @@ payloads, and semantics change compatibly or with notice.

| Event | What it is for |
|---|---|
| `session:task_ready` | What the task will cost in credits, and whether it is authorised. When the balance covers it the server starts the task itself and this is informational; when it does not, the session waits |
| `session:task_finished` | The result. `completion.result_title`, `result_description` and `outcome_narrative` carry the text; `completion.summary` is quantified, and `brief` is its only prose |
| `session:tool_status` | The record of one asynchronous operation. An outbound call reports here: the number, the duration, the credits, and `summary.text`. It updates in place, reusing its `message_id`, so expect several with the same one |

Expand Down Expand Up @@ -170,26 +167,35 @@ has stopped.
`rebuild()` returns messages of every type, including unsupported ones.
Filtering them is yours to do.

## Blocked sessions
## When a session cannot proceed

When the composer is disabled, `session:input_state` carries the reason. Read it
from there rather than inferring it from which events did or did not arrive.
`session:state` reports where the task stands, and several of its values say
that nothing further will arrive until something changes outside the session:

```python
from pine_assistant import InputState, S2CEvent

if event.type == S2CEvent.SESSION_INPUT_STATE:
state = InputState.model_validate(event.data)
if state.awaiting_credits:
... # cost is on session:task_ready; retry once the balance is restored
if state.needs_phone_verification:
... # no in-session remedy
from pine_assistant import S2CEvent

if event.type == S2CEvent.SESSION_STATE:
state = (event.data or {}).get("content")
if state in ("credits_exhausted", "task_paused"):
... # waiting on the account, not on the agent
if state in ("task_finished", "task_cancelled"):
... # the task is over
```

An expired session has no reason code of its own — it presents only as a
disabled composer. Expiry is the `is_stale` field on the session object, over
REST. On finding one expired, create a new session and reference the old one in
your first message:
Two more events state a stop outright:

```python
if event.type == S2CEvent.SESSION_RESTRICTION:
... # an account restriction — the task will not complete
if event.type == S2CEvent.SESSION_ERROR:
... # the only channel for server-reported failures
```

An expired session is read over REST, from the `is_stale` field on the session
object — expiry is a property of the session, not one of its states. On finding
one expired, create a new session and reference the old one in your first
message:

```python
new = await client.sessions.create()
Expand All @@ -202,7 +208,8 @@ Two conditions have no remedy once a session is running:

- **Metered billing.** The account must be billed against a credit balance. On
the alternative path a session halts at a payment step the SDK cannot answer.
- **Phone verification.** Must be completed at provisioning time.
- **Phone verification.** Must be completed at provisioning time. It has no
in-session remedy and no in-session signal.

## Attachments

Expand Down
3 changes: 0 additions & 3 deletions src/pine_assistant/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
S2CEvent,
is_supported_event,
)
from pine_assistant.models.session import InputState, InputStateCode
from pine_assistant.sessions import SessionsAPI

__version__ = "0.4.0"
Expand All @@ -37,6 +36,4 @@
"S2CEvent",
"SUPPORTED_EVENTS",
"is_supported_event",
"InputState",
"InputStateCode",
]
6 changes: 0 additions & 6 deletions src/pine_assistant/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from typing import Any

from pine_assistant.models.events import C2SEvent, S2CEvent
from pine_assistant.models.session import ACCEPTING_INPUT
from pine_assistant.transport.socketio import SocketIOManager

TERMINAL_STATES = {"task_finished", "task_cancelled", "task_stale"}
Expand All @@ -30,7 +29,6 @@
S2CEvent.SESSION_TEXT_PART,
S2CEvent.SESSION_RICH_CONTENT,
S2CEvent.SESSION_FORM_TO_USER,
S2CEvent.SESSION_TASK_READY,
S2CEvent.SESSION_TASK_FINISHED,
S2CEvent.SESSION_TOOL_STATUS,
S2CEvent.SESSION_RESTRICTION,
Expand Down Expand Up @@ -202,10 +200,6 @@ def handler(event: str, raw: dict[str, Any]) -> None:
if event in SUBSTANTIVE_EVENTS:
received_agent_response = True
data = payload.get("data")
if (event == S2CEvent.SESSION_INPUT_STATE and isinstance(data, dict)
and data.get("content") == ACCEPTING_INPUT and received_agent_response):
done = True
queue.put_nowait(None)
if (event == S2CEvent.SESSION_STATE and isinstance(data, dict)
and data.get("content", "") in TERMINAL_STATES):
done = True
Expand Down
3 changes: 0 additions & 3 deletions src/pine_assistant/models/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,14 @@ class S2CEvent(StrEnum):

# Session state
SESSION_STATE = "session:state"
SESSION_INPUT_STATE = "session:input_state"
SESSION_MESSAGE_STATUS = "session:message_status"
SESSION_REQUIRED_ACTION = "session:required_action"
SESSION_UPDATE_TITLE = "session:update_title"
SESSION_RESTRICTION = "session:restriction"

# Interaction
SESSION_FORM_TO_USER = "session:form_to_user"

# Task and result
SESSION_TASK_READY = "session:task_ready"
SESSION_TASK_FINISHED = "session:task_finished"
SESSION_TOOL_STATUS = "session:tool_status"

Expand Down
68 changes: 2 additions & 66 deletions src/pine_assistant/models/session.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,15 @@
"""
Session models — REST session objects and the `session:input_state` payload.
Session models — the REST session object.
"""

import sys

from pydantic import BaseModel

if sys.version_info >= (3, 11):
from enum import StrEnum
else:
from enum import Enum

class StrEnum(str, Enum):
pass


class SessionInfo(BaseModel):
id: str
type: str | None = None
title: str = ""
# Expiry is carried here and nowhere on the Socket.IO surface: an expired
# session presents only as a disabled composer, with no code that tells it
# apart from other causes.
# Expiry is carried here and nowhere on the Socket.IO surface.
is_stale: bool | None = None
is_processed: bool | None = None
state: str = "init"
Expand All @@ -35,55 +23,3 @@ class SessionListResponse(BaseModel):
total: int
limit: int
offset: int


class InputStateCode(StrEnum):
"""Reason codes on `session:input_state`."""
DEFAULT = "default"
TASK_READY = "task_ready"
TASK_PROCESSING = "task_processing"
PROFILE_UPDATE_REQUIRED = "profile_update_required"
SESSION_SUMMARY = "session_summary"
PHONE_VERIFICATION_REQUIRED = "phone_verification_required"


ACCEPTING_INPUT = "waiting_input"


class InputState(BaseModel):
"""`session:input_state` payload.

The blocking condition is read from `code`, never inferred from which other
events did or did not arrive — the events that elaborate on a condition are
mostly outside the supported scope.
"""
content: str = ""
detail: str = ""
code: str = ""

@property
def accepting_input(self) -> bool:
return self.content == ACCEPTING_INPUT

@property
def blocked(self) -> bool:
return not self.accepting_input

@property
def awaiting_credits(self) -> bool:
"""Blocked on an unconfirmed credit charge.

The cost is carried by `session:task_ready`. When the balance covers it
the server starts the task itself; when it does not, the session waits
here until the balance is restored.
"""
return self.blocked and self.code == InputStateCode.TASK_READY

@property
def needs_phone_verification(self) -> bool:
"""Blocked on phone verification.

A provisioning prerequisite: it has no in-session remedy, and the event
that explains it is outside the supported scope.
"""
return self.blocked and self.code == InputStateCode.PHONE_VERIFICATION_REQUIRED
21 changes: 2 additions & 19 deletions src/pine_assistant/models/task.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,13 @@
"""
Task models — `session:task_ready`, `session:task_finished`, `session:tool_status`,
`session:llm_thinking`, `session:restriction`, `session:required_action`.
Task models — `session:task_finished`, `session:tool_status`,
`session:llm_thinking`, `session:restriction`.
"""

from typing import Any

from pydantic import BaseModel


class TaskReadyData(BaseModel):
"""`session:task_ready` payload.

Informational when the balance covers `required`; when it does not, the
session waits until the balance is restored.
"""
required: int = 0
suggested: int | None = None
confirmed: bool = False


class Achievement(BaseModel):
id: str = ""
title: str = ""
Expand Down Expand Up @@ -123,12 +112,6 @@ class RestrictionData(BaseModel):
message: str | None = None


class RequiredActionData(BaseModel):
"""`session:required_action` payload — whether the session awaits a user
response."""
is_required_action: bool = False


class MessageStatusData(BaseModel):
"""`session:message_status` payload — the only means of telling a rejected
or rate-limited message from one still being processed."""
Expand Down
15 changes: 1 addition & 14 deletions tests/integration/test_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import pytest

from pine_assistant import AsyncPineAI, InputState, S2CEvent, is_supported_event
from pine_assistant import AsyncPineAI, S2CEvent, is_supported_event

SKIP = not os.environ.get("PINE_INTEGRATION")
ACCESS_TOKEN = os.environ.get("PINE_ACCESS_TOKEN", "")
Expand Down Expand Up @@ -124,19 +124,6 @@ async def test_rebuild_returns_the_conversation(self, session):
assert isinstance(messages, list)
assert messages, "history came back empty after a turn"

async def test_input_state_reports_whether_the_composer_is_open(self, session):
client, sid = session
states = [
InputState.model_validate(e.data)
async for e in client.chat(sid, PROMPT)
if e.type == S2CEvent.SESSION_INPUT_STATE and isinstance(e.data, dict)
]
assert states, "no session:input_state during a turn"
# Whatever the value, a blocked composer must name its reason.
for state in states:
if state.blocked:
assert state.code or state.detail


class TestErrors:
async def test_get_nonexistent_session(self):
Expand Down
20 changes: 0 additions & 20 deletions tests/protocol/fixtures/input_state.json

This file was deleted.

15 changes: 0 additions & 15 deletions tests/protocol/fixtures/provenance.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,6 @@
"recorded_at": "2026-08-08T13:55:16+00:00",
"source": "recorded"
},
"session:input_state": {
"derived_from": null,
"recorded_at": "2026-08-08T13:55:16+00:00",
"source": "recorded"
},
"session:join": {
"derived_from": null,
"recorded_at": "2026-08-08T13:55:16+00:00",
Expand All @@ -39,11 +34,6 @@
"recorded_at": "2026-08-08T13:55:16+00:00",
"source": "recorded"
},
"session:required_action": {
"derived_from": null,
"recorded_at": "2026-08-08T13:48:53+00:00",
"source": "recorded"
},
"session:restriction": {
"derived_from": "the server's protocol definition",
"recorded_at": null,
Expand All @@ -64,11 +54,6 @@
"recorded_at": "2026-08-08T13:55:16+00:00",
"source": "recorded"
},
"session:task_ready": {
"derived_from": null,
"recorded_at": "2026-08-08T13:55:16+00:00",
"source": "recorded"
},
"session:text": {
"derived_from": null,
"recorded_at": "2026-08-08T13:55:16+00:00",
Expand Down
Loading
Loading