Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions changes/211.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**Other apps can chat with Sentient through an OpenAI-compatible API.** An editor, script or shortcut that speaks
the OpenAI API can get Sentient's replies, with its memory and tools, in a chat of its own. Anything that needs
your OK still asks you in Sentient (and on your phone when you're away), and the app hears plainly when something
wasn't done. Requests from another app never count as your personal permission to run code or send data out.
It is off until you turn it on, and each app needs Sentient's API key.
5 changes: 3 additions & 2 deletions desktop/src/features/channels/meta.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IconDeviceMobile, IconEyeglass2, IconMicrophone, IconMessageCircle, type Icon } from '@tabler/icons-react'
import { IconApi, IconDeviceMobile, IconEyeglass2, IconMicrophone, IconMessageCircle, type Icon } from '@tabler/icons-react'
import { siDiscord, siTelegram, siWhatsapp, type SimpleIcon } from 'simple-icons'
import { Tooltip } from '@/components/ui'
import { cn } from '@/lib/utils'
Expand All @@ -18,7 +18,8 @@ export const CHANNEL_INFO: Record<string, ChannelInfo> = {
whatsapp: { label: 'WhatsApp', from: 'From WhatsApp', mark: { kind: 'si', icon: siWhatsapp } },
voice: { label: 'Voice', from: 'A voice conversation', mark: { kind: 'tabler', icon: IconMicrophone } },
glasses: { label: 'Glasses', from: 'From your glasses', mark: { kind: 'tabler', icon: IconEyeglass2 } },
phone: { label: 'Phone', from: 'From your phone', mark: { kind: 'tabler', icon: IconDeviceMobile } }
phone: { label: 'Phone', from: 'From your phone', mark: { kind: 'tabler', icon: IconDeviceMobile } },
api: { label: 'Another app', from: 'From another app (API)', mark: { kind: 'tabler', icon: IconApi } }
}

export function channelInfo(channel: string | null | undefined): ChannelInfo | null {
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,11 @@ export interface GatewayConfig {
port: number
}

/** The OpenAI-compatible chat API (#211, docs/API.md section 2). */
export interface OpenAIApiConfig {
enabled: boolean
}

export interface ChatConfig {
history_window: number
compress_after_messages: number
Expand Down Expand Up @@ -454,6 +459,7 @@ export interface SentientConfig {
assistant: AssistantConfig
models: ModelsConfig
gateway: GatewayConfig
openai_api: OpenAIApiConfig
chat: ChatConfig
memory: MemoryConfig
tools: ToolsConfig
Expand Down
45 changes: 45 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,47 @@ accept or loosen a rule.
ends, so calls already running when the budget is reached (a swarm's workers, a chat and a task at once) still
finish, and spending can go past the budget by what they cost.

### OpenAI-compatible API (#211)
Lets other apps on this computer (editors, scripts, shortcuts) chat with Sentient, with its memory and tools, using
any OpenAI client pointed at `<engine>/v1`. Off until `openai_api.enabled` is `true` (`PATCH /api/config`).
- `POST /api/openai-api/key` (window token) → `{key, base_url}`: a new key (`sk-sentient-...`) in the keychain entry
`openai_api_key`, replacing the old one. This answer is the only place it is shown. 503 when the keychain can't
save it.
- `/v1/...` takes `Authorization: Bearer <key>`, never the window's token. Errors use OpenAI's shape
`{error: {message, type, param, code}}`: 404 `api_off` while the setting is off, 401 `invalid_api_key` for a missing
or wrong key, 400 `no_user_message`, 500 `turn_failed` when the turn ended in an error.
- `GET /v1/models` → `{object: "list", data: [{id: "sentient", object: "model", created, owned_by}]}`.
- `POST /v1/chat/completions` `{model, messages: [{role, content}], stream?, user?}` runs one chat turn in the named
chat for `user` (`"API"` when absent, made on first use, `channel: "api"`, title = the name; the mapping is the
`openai_api.sessions` meta entry). Only the last `user` message is sent (its text parts when `content` is a list):
Sentient keeps the chat's history itself, so earlier messages, `system` messages and fields such as `temperature`
are ignored. Without `stream` → a `chat.completion` with the reply and `usage`; with `stream: true` →
`text/event-stream` of `chat.completion.chunk`s (a first `delta: {role: "assistant"}`, `delta.content` per
`text_delta`, a last `delta: {}` with `finish_reason: "stop"`), a `data: {error}` line if the turn failed, then
`data: [DONE]`. API turns have no undo window (`channel: "api"` is not attended, section 10).
- **Approvals still ask in the app.** A call that needs the user's OK (section 2, rules and modes as in any chat)
posts a `kind: "approval"` notification whose `payload` is the turn's `approval_request` (section 1) plus
`session_id` and `source: "api"`: the window shows it with Allow and Deny (answered with `POST /api/approvals`), and
paired chats get it like a task's approval (section 14). The request keeps waiting meanwhile. Once answered,
`payload.status` is `allow` or `deny`; `expired` when nobody answered within `tools.approvals.timeout_s` or the turn
was stopped (`notification.updated`). A call that was declined or not answered in time is never claimed: the reply
(or a last `delta.content`) ends with `Not done: "<tool>" needed your OK in Sentient and didn't get it.`
- API messages are outside content from the start of each turn, including in an existing chat. Their text never
counts as the user's own words for an "Only if I asked" rule or a standing-rule proposal, and does not use the
quick-command shortcut. Calls that run code or send data out ask for approval even with an Allow rule or approvals
turned off (ADR 0018). A desktop user's explicit approval is still required. Facts learned from API messages stay
pending for review rather than being accepted as the user's own statements (ADR 0021).
Stored API user messages retain `name: "another app (API)"`; their text is marked as outside content when read
into a later prompt. Trusting a chat up to an older message does not cover newer API input.
- Concurrent first requests share the same chat for a given name, and creating one name cannot overwrite another
name's chat mapping.
- **Stop everything** (`POST /api/stop-all`) ends a running API turn, waiting approvals included: 409 `stopped`
(`Stopped.`), or a `data: {error: {message: "Stopped."}}` line before `[DONE]` when streaming. A client that hangs
up doesn't end the turn: it runs to the end and is saved, as in the window.
- Errors carry `x-should-retry: false`, so OpenAI's SDKs don't retry a failed turn and run its tools again.
- The chat list marks these chats "From another app (API)", and Activity says
`From another app (API), in the chat "<name>"` for what they did.

---

## 3. Models & secrets (fully changeable model configuration)
Expand Down Expand Up @@ -2886,6 +2927,10 @@ A device ("node") is a phone, a pair of smart glasses, a watch, or the desktop a
that delivered it to that chat (`channel_questions`); `tq:` buttons never answer an approval. A reply with a word or
an option number answers it like any task question. Unpaired chats are refused as always. A request past its time
can't be approved (the toast and the message say it expired).
- Approval requests of chat turns started without a window (the OpenAI-compatible API, section 2, #211) arrive as
`kind: "approval"` notifications and are held and sent the same way, as the channel's own approval message: one
button per offered answer (`ap:<code>:<approval_id>`, the same buttons as a chat's approval), answering through
`app.approvals`. When the request is answered anywhere, expires or is stopped, the message's buttons settle.
- Delivery (chats with `deliver: true`, from `notification.new`; a task's `deliver_to` can keep its notifications on
the desktop or send them only to chosen chats instead, section 4 "Where results go"): task results and failures
(`payload.event` in `run_completed`, `run_failed`, `planning_failed`, `clarification_needed`, `disabled`,
Expand Down
2 changes: 2 additions & 0 deletions sentient/activity/timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ def _where_chat(self, channel: str | None, title: str | None) -> str:
if channel == "voice":
return "You asked by voice"
name = (title or "").strip()
if channel == "api": # the OpenAI-compatible API (#211): another app asked, not the user
return f'From another app (API), in the chat "{words.short(name, 60)}"' if name else "From another app (API)"
return f'You asked in the chat "{words.short(name, 60)}"' if name else "You asked in a chat"

def _chat_item(self, row: Any) -> Item:
Expand Down
59 changes: 38 additions & 21 deletions sentient/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
from sentient.tools.builtin.time_tool import resolve_tz
from sentient.tools.registry import ToolRegistry
from sentient.tools.rules import (
API_SOURCE,
ASK,
EVENT_ROLE,
HAND_OFF,
Expand Down Expand Up @@ -265,6 +266,8 @@ def history_to_openai(rows: list[dict]) -> list[dict]:
content = r.get("content") or ""
if r.get("attachments"):
content += "\n\n(Attached earlier: " + ", ".join(r["attachments"]) + ")"
if role == "user" and r.get("name") == API_SOURCE:
content = mark_outside(content, API_SOURCE)
out.append({"role": role, "content": content})

# every assistant tool call needs its reply right after it; tool messages need their call
Expand Down Expand Up @@ -1673,7 +1676,10 @@ async def run_turn(
) -> AsyncIterator[AgentEvent]:
"""One chat turn. ``on_stopped`` is called with ``{message_id, memory_sources}`` of the reply kept when the
turn is stopped, so the caller can send them with its own ``done`` event."""
quick = await self._quick_plan(session_id, user_text, attachments or [])
# An authenticated app can request work, but its text is not the user's personal authorization (#211).
# Mark before any planning, and retain the mark even if the user trusts the stored chat during this turn.
api_source = await self._mark_chat(session_id, API_SOURCE) if channel == "api" else ""
quick = None if api_source else await self._quick_plan(session_id, user_text, attachments or [])
if quick is not None: # a plain timer or reminder command: done now, without the model (#150)
async for event in self._quick_turn(session_id, user_text, quick, channel=channel):
yield event
Expand Down Expand Up @@ -1715,10 +1721,16 @@ def release() -> list[str]:
used = AnswerSources() # pages, emails, files and apps it used (#174)
notes: list[dict] = [] # pieces of the user's notes put into the prompt (#354)
try:
user_message_id = await self.store.add_message(session_id, "user", user_text, attachments=attachments)
if self.app is not None and hasattr(self.app, "user_seen"):
user_message_id = await self.store.add_message(
session_id, "user", user_text, attachments=attachments, name=API_SOURCE if api_source else None,
)
if api_source:
# A trust request may have cleared the early mark before this message was stored. Now its saved
# source also prevents trusting an older screen snapshot from covering this new API input.
await self._mark_chat(session_id, api_source)
if not api_source and self.app is not None and hasattr(self.app, "user_seen"):
await self.app.user_seen(channel) # a chat message on any surface means the user is around (#163)
rule_check = self._start_rule_check(session_id, user_message_id, user_text)
rule_check = None if api_source else self._start_rule_check(session_id, user_message_id, user_text)
await self.store.touch_session(session_id, title=(user_text or (attachments[0] if attachments else ""))[:60])

session = await self.store.get_session(session_id)
Expand All @@ -1731,7 +1743,7 @@ def release() -> list[str]:
# Keep the stored user message unchanged. Add the current time to the
# model-facing user message so it doesn't invalidate the system prompt cache.
if convo and convo[-1]["role"] == "user":
content = await self._user_content(user_text, attachments)
content = await self._user_content(mark_outside(user_text, API_SOURCE) if api_source else user_text, attachments)
tz = resolve_tz(self.config.assistant.timezone)
now = datetime.now(tz)
time_note = f"[Current time: {now.strftime('%A %Y-%m-%d %H:%M')} ({now.tzinfo})]"
Expand Down Expand Up @@ -1759,9 +1771,9 @@ def release() -> list[str]:
ctx = self.tool_context(session_id, channel)
ctx.incognito = incognito
ctx.memory_scope = memory_scopes.of_session(self.config, session) # recall and saving use it (#155)
ctx.user_words = [user_text] # what the user typed or said, never attachments (#140)
ctx.user_words = [] if api_source else [user_text] # the user's own words, never API input or attachments
# outside content read earlier in this chat still counts until a new chat starts (ADR 0018)
ctx.untrusted = await self._chat_untrusted(session_id, session)
ctx.untrusted = api_source or await self._chat_untrusted(session_id, session)
brought = next((src for a in attachments if (src := attachment_source(a))), "")
if not ctx.untrusted and brought:
# a shared window or region, or text selected in another app, can hold text someone else wrote: it
Expand Down Expand Up @@ -1869,7 +1881,8 @@ async def persist(role_: str, content: str | None, **fields: Any) -> None:
# a chat that read outside content holds what it learns for the user's review (ADR 0021)
log.info("chat %s: fact extraction started for this turn", session_id)
self._spawn(
self._extract(said, memory_review.for_context(ctx), session_id, ctx.memory_scope), "memory"
self._extract(said, memory_review.for_context(ctx), session_id, ctx.memory_scope,
own=not bool(api_source)), "memory"
)
if self.config.chat.auto_title and session and (session.get("title") or "") == (user_text or "")[:60]:
self._spawn(self._auto_title(session_id, user_text, result.text), "titles")
Expand Down Expand Up @@ -2018,8 +2031,8 @@ async def _chat_untrusted(self, session_id: str, session: dict | None) -> str:
return str(stored)
rows = await self.store.fetchall(
"SELECT role, name, content, tool_calls, tool_call_id FROM messages WHERE session_id = ?"
" AND role IN ('tool', 'assistant') ORDER BY created_at, rowid",
(session_id,),
" AND (role IN ('tool', 'assistant') OR (role = 'user' AND name = ?)) ORDER BY created_at, rowid",
(session_id, API_SOURCE),
)
transcript = []
for r in rows:
Expand Down Expand Up @@ -2059,12 +2072,15 @@ async def add_group_messages(self, session_id: str, messages: list[tuple[str, st
async def mark_group_chat(self, session_id: str, sender: str) -> str:
"""Mark a chat because a group member wrote in it (ADR 0029), unless it is marked already. Done as soon as the
message arrives, also while it waits for a reply to end. Returns the chat's mark."""
return await self._mark_chat(session_id, group_source(sender))

async def _mark_chat(self, session_id: str, source: str) -> str:
"""Persist incoming outside content before it can start work, preserving an existing source."""
async with self._mark_locks.setdefault(session_id, asyncio.Lock()):
session = await self.store.get_session(session_id)
current = (session or {}).get("untrusted")
if current:
return str(current)
source = group_source(sender)
await self.store.execute("UPDATE sessions SET untrusted = ? WHERE id = ?", (source, session_id))
self._publish_mark(session_id, source)
return source
Expand All @@ -2077,16 +2093,16 @@ async def last_message_id(self, session_id: str) -> str | None:
return str(row["id"]) if row else None

async def _newer_outside(self, session_id: str, upto: str) -> tuple[str, str] | None:
"""(source, writer) of the newest outside content stored after message ``upto``: a group member's message or
a tool result that brought outside content in. None when there is none. ValueError for a message that isn't
in this chat."""
"""(source, writer) of the newest outside content after ``upto``: a group member, API input or a tool result.
None when there is none. ValueError for a message that isn't in this chat."""
row = await self.store.fetchone("SELECT rowid FROM messages WHERE id = ? AND session_id = ?", (upto, session_id))
if row is None:
raise ValueError("that message isn't in this chat")
rows = await self.store.fetchall(
"SELECT role, name, content FROM messages WHERE session_id = ? AND rowid > ? AND role IN (?, 'tool')"
"SELECT role, name, content FROM messages WHERE session_id = ? AND rowid > ?"
" AND (role IN (?, 'tool') OR (role = 'user' AND name = ?))"
" ORDER BY rowid DESC",
(session_id, row[0], OTHER_ROLE),
(session_id, row[0], OTHER_ROLE, API_SOURCE),
)
for r in rows:
if r["role"] == OTHER_ROLE:
Expand Down Expand Up @@ -2238,18 +2254,19 @@ def _publish_memory(self, results: Any, session_id: str | None = None) -> None:
)

async def _extract(
self, user_text: str, review: dict | None = None, session_id: str | None = None, scope: str | None = None
self, user_text: str, review: dict | None = None, session_id: str | None = None, scope: str | None = None,
*, own: bool = True,
) -> None:
"""Only the user's own words are mined for facts; the assistant's reply is
mostly restated context (dates, tool output) and produced junk memories.
"""Mine incoming text for facts; the assistant's reply is mostly restated context and produced junk memories.
With ``review`` (the chat read outside content) a fact is held for the user's review unless the user's own
words back all of it (#234); instruction-shaped ones are always held. New facts go in the chat's ``scope``."""
words back all of it (#234); API input never counts as own words. Instruction-shaped facts are always held.
New facts go in the chat's ``scope``."""
assert self.memory is not None
if len(user_text.split()) < 4:
return
try:
results = await self.memory.extract_and_store(
user_text, self.config.assistant.user_name, own=True, session_id=session_id,
user_text, self.config.assistant.user_name, own=own, session_id=session_id,
**({"review": review} if review else {}),
**({"scope": scope} if scope and scope != memory_scopes.PERSONAL else {}),
)
Expand Down
Loading
Loading