@yushaw/sanqian-sdk
v0.3.28
Published
Sanqian SDK for third-party app integration
Downloads
783
Readme
Sanqian Developer Guide / 三千开发者指南
English
Sanqian provides two ways to integrate with external applications:
| Method | Use Case | Protocol | |--------|----------|----------| | HTTP API | Simple chat, any language | REST + SSE | | SDK | Tool registration, agents, context injection, deep integration | WebSocket |
Quick Start
HTTP API
# Get port (Sanqian writes it on startup)
PORT=$(cat ~/.sanqian/runtime/api.port)
# Chat with default agent
curl -X POST "http://localhost:$PORT/api/agents/default/chat" \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "Hello"}], "stream": false}'SDK
npm install @yushaw/sanqian-sdkimport { SanqianSDK } from '@yushaw/sanqian-sdk'
const sdk = new SanqianSDK({
appName: 'my-app',
appVersion: '1.0.0',
tools: [{
name: 'greet',
description: 'Greet a user',
parameters: {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
},
handler: async ({ name }) => `Hello, ${name}!`
}]
})
// SDK auto-connects when Sanqian starts (via connection.json file watching).
// If Sanqian is already running, it connects immediately.
// If autoLaunchSanqian is true (default), it launches Sanqian if not running.Tools
Tools are the primary way your app provides capabilities to Sanqian. When a user asks a question, the AI agent can call your tools to get data or perform actions.
Define Tools
Each tool needs a name, description (for the LLM), JSON Schema parameters, and a handler function.
const sdk = new SanqianSDK({
appName: 'my-notes-app',
appVersion: '1.0.0',
tools: [
{
name: 'search_notes',
description: 'Search through user notes by keyword',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search keyword' },
limit: { type: 'number', description: 'Max results', default: 10 }
},
required: ['query']
},
handler: async ({ query, limit }) => {
const results = await db.searchNotes(query, limit)
return results.map(n => ({ title: n.title, snippet: n.snippet }))
}
},
{
name: 'create_note',
description: 'Create a new note',
parameters: {
type: 'object',
properties: {
title: { type: 'string' },
content: { type: 'string' }
},
required: ['title', 'content']
},
handler: async ({ title, content }) => {
const note = await db.createNote(title, content)
return { id: note.id, title: note.title }
}
}
]
})Tool names are automatically prefixed with your app name. search_notes becomes my-notes-app:search_notes in Sanqian.
Update Tools at Runtime
You can add, remove, or modify tools after initialization:
await sdk.updateTools([
{ name: 'search_notes', /* updated definition */ handler: newHandler },
{ name: 'new_tool', /* ... */ handler: newToolHandler }
])This replaces the entire tool list. The change takes effect immediately for new agent runs.
Tool Searchability
By default, tools are discoverable via Sanqian's search_capability tool (used by agents to find relevant tools). Set searchable: false to hide a tool from search while keeping it available:
{
name: 'internal_sync',
description: 'Internal data sync',
searchable: false, // Available but not discoverable via search
parameters: { type: 'object' },
handler: async () => { /* ... */ }
}Agents
Private agents are custom AI personas with specific tools, skills, and instructions. They appear in Sanqian's agent picker.
Create an Agent
const agent = await sdk.createAgent({
agent_id: 'notes-assistant',
name: 'Notes Assistant',
description: 'Helps manage and search notes',
system_prompt: 'You are a notes assistant. Help users organize their notes.',
tools: ['search_notes', 'create_note'], // Your SDK tools (auto-prefixed)
skills: ['web-research'], // Sanqian built-in skills
subagents: ['*'], // Can delegate to any agent
searchable: true, // Discoverable by other agents
})
console.log(agent.agent_id) // "my-notes-app:notes-assistant"Tool Name Formats
The tools array in agent config supports multiple formats:
| Format | Example | Description |
|--------|---------|-------------|
| Short name | "search_notes" | Your SDK tool (auto-prefixed with app name) |
| Full SDK name | "other-app:tool_name" | Another SDK app's tool |
| Built-in | "read_file", "run_bash_command" | Sanqian built-in tools |
| MCP | "mcp_servername_toolname" | MCP server tools |
| Wildcard | ["*"] | All available tools |
Sub-agents
Control whether your agent can delegate tasks to other agents:
{
subagents: undefined, // Cannot use task tool (default)
subagents: [], // Cannot use task tool (explicit)
subagents: ['*'], // Can call any agent
subagents: ['agent1', 'agent2'] // Can only call specific agents
}Update and Delete
// Update specific fields (others unchanged)
await sdk.updateAgent('notes-assistant', {
system_prompt: 'Updated instructions...',
tools: ['search_notes', 'create_note', 'delete_note']
})
// Delete
await sdk.deleteAgent('notes-assistant')
// List your agents
const agents = await sdk.listAgents()Chat
Send messages to any agent and get responses. Supports both streaming and non-streaming modes.
Non-streaming
const response = await sdk.chat('notes-assistant', [
{ role: 'user', content: 'Find my notes about TypeScript' }
])
console.log(response.message.content)
console.log(response.conversationId) // Empty string if statelessStreaming
for await (const event of sdk.chatStream('notes-assistant', [
{ role: 'user', content: 'Summarize my recent notes' }
])) {
switch (event.type) {
case 'start':
// Stream started, event.run_id available
break
case 'text':
process.stdout.write(event.content || '')
break
case 'thinking':
// Reasoning content (DeepSeek R1, o3, etc.)
break
case 'tool_call':
console.log(`Calling: ${event.tool_call?.function.name}`)
break
case 'tool_args_chunk':
// Streaming tool arguments (partial JSON)
break
case 'tool_result':
console.log(`Result: ${event.success ? 'ok' : event.error}`)
break
case 'done':
console.log(`\nConversation: ${event.conversationId}`)
break
case 'error':
console.error(event.error)
break
}
}Stream Events
| Event | Fields | Description |
|-------|--------|-------------|
| start | run_id, conversationId | Stream started |
| text | content | Text chunk from the AI |
| thinking | content | Reasoning content (thinking models) |
| tool_call | tool_call | Tool invocation started |
| tool_args_chunk | tool_call_id, tool_name, chunk | Streaming tool arguments |
| tool_args | tool_call_id, tool_name, args | Complete tool arguments |
| tool_result | tool_call_id, result, success, error | Tool execution result |
| interrupt | interrupt_type, interrupt_payload, run_id | HITL pause (see below) |
| done | conversationId, title | Stream finished |
| error | error | Error occurred |
| cancelled | run_id | Run was cancelled |
Stateful vs Stateless
// Stateless: you manage message history
const r1 = await sdk.chat('agent', messages)
// r1.conversationId is empty string
// Stateful: server manages history
const r1 = await sdk.chat('agent', messages, {
persistHistory: true // Creates a server-side conversation
})
// r1.conversationId is set, use it for follow-ups
const r2 = await sdk.chat('agent', [{ role: 'user', content: 'Follow up' }], {
conversationId: r1.conversationId
})Conversation Helper
For multi-turn conversations, use the Conversation helper:
const conv = sdk.startConversation('notes-assistant')
const r1 = await conv.send('Find my TypeScript notes')
console.log(r1.message.content)
const r2 = await conv.send('Summarize the first one')
console.log(conv.id) // Conversation ID (available after first send)
// Streaming
for await (const event of conv.sendStream('Any more details?')) {
if (event.type === 'text') process.stdout.write(event.content || '')
}
// Get history
const details = await conv.getDetails({ messageLimit: 50 })
// Delete
await conv.delete()Cancel a Run
let runId: string | undefined
for await (const event of sdk.chatStream('agent', messages)) {
if (event.type === 'start') {
runId = event.run_id
}
if (shouldCancel) {
sdk.cancelRun(runId!)
break
}
}Auto-discovery
Enable automatic discovery of skills, tools, or sub-agents for a chat call:
const response = await sdk.chat('agent', messages, {
autoDiscoverSkills: true, // Agent can find and use skills
autoDiscoverTools: true, // Agent can find and use tools
autoDiscoverSubagents: true // Agent can delegate to other agents
})Local file ingest
For same-machine deployments where the Sanqian backend can read a local path directly, use uploadFile() first, then pass the returned file.path into chat():
const uploaded = await sdk.uploadFile('/absolute/path/report.pdf', {
conversationId,
autoIndex: true,
asyncProcess: true,
})
const response = await sdk.chat('agent', messages, {
conversationId: uploaded.conversationId,
filePaths: [uploaded.file.path],
})uploadFile(localPath) is not a remote file transfer protocol. For remote/backend-on-another-machine cases, continue using explicit byte upload APIs.
Human-in-the-Loop (HITL)
HITL allows the agent to pause and ask for user input during a run. There are three interrupt types:
| Type | Use Case | Example |
|------|----------|---------|
| approval_request | Pre-execution approval | "Delete file X?" -> Approve/Reject |
| user_input_request | Ask for user input | "What format?" -> ["JSON", "CSV"] or free text |
| user_action_request | User completes external action | "Login required" -> User logs in -> "Done" |
Handling Interrupts
for await (const event of sdk.chatStream('agent', messages)) {
if (event.type === 'interrupt') {
const { interrupt_type, interrupt_payload, run_id } = event
if (interrupt_type === 'approval_request') {
const approved = await showApprovalDialog(interrupt_payload)
sdk.sendHitlResponse(run_id!, { approved })
}
if (interrupt_type === 'user_input_request') {
const answer = await showInputDialog(interrupt_payload)
sdk.sendHitlResponse(run_id!, { answer })
}
if (interrupt_type === 'user_action_request') {
await waitForUserAction(interrupt_payload)
sdk.sendHitlResponse(run_id!, { approved: true })
}
}
}HITL Response Options
sdk.sendHitlResponse(runId, {
approved: true, // For approval_request
remember: true, // Remember this choice for future
answer: 'JSON', // For user_input_request (text)
selected_indices: [0, 2], // For multi-select options
cancelled: true, // User cancelled
timed_out: true, // Request timed out
})Conversations
Manage conversation history stored on the server.
// List conversations (optionally filter by agent)
const { conversations, total } = await sdk.listConversations({
agentId: 'notes-assistant',
limit: 20,
offset: 0
})
// Get conversation with messages
const detail = await sdk.getConversation(conversationId, {
includeMessages: true,
messageLimit: 50,
messageOffset: 0
})
// Get message history (via HTTP API, aligned with main app)
const history = await sdk.getMessages(conversationId, {
limit: 50,
offset: 0
})
// Returns: { messages, has_more, session_id, returned_turns }
// Delete a conversation
await sdk.deleteConversation(conversationId)Context Providers
Context providers let your app inject dynamic state into conversations. When the user (or agent) attaches a context, Sanqian calls your provider to fetch the latest data.
Three provider methods, all optional:
| Method | Purpose | When Called |
|--------|---------|------------|
| getCurrent() | Get current state | User sends message with context attached |
| getList(options) | List available resources | User opens "+" menu to browse |
| getById(id) | Get specific resource | User selects item from list |
Register Context Providers
const sdk = new SanqianSDK({
appName: 'my-notes-app',
appVersion: '1.0.0',
tools: [/* ... */],
contexts: [
{
id: 'active-note',
name: 'Active Note',
description: 'The note currently being edited',
getCurrent: async () => ({
content: editor.getCurrentNote().content,
title: editor.getCurrentNote().title,
type: 'note',
}),
},
{
id: 'notes',
name: 'Notes Library',
description: 'Browse and attach notes',
getList: async (options) => {
const notes = await db.searchNotes(options?.query || '', {
offset: options?.offset || 0,
limit: options?.limit || 20,
})
return {
items: notes.map(n => ({
id: n.id,
title: n.title,
summary: n.snippet,
type: 'note',
group: n.folder,
})),
hasMore: notes.length === (options?.limit || 20),
}
},
getById: async (id) => {
const note = await db.getNote(id)
if (!note) return null
return {
id: note.id,
content: note.content,
title: note.title,
type: 'note',
}
},
}
]
})Attach Contexts to Agents
Auto-attach context providers to an agent so they're always included:
await sdk.createAgent({
agent_id: 'notes-assistant',
name: 'Notes Assistant',
tools: ['search_notes'],
attached_contexts: ['active-note'], // Auto-prefixed with app name
})Update Contexts at Runtime
await sdk.updateContexts([
{ id: 'active-note', name: 'Active Note', description: '...', getCurrent: newHandler },
{ id: 'new-context', name: 'New Context', description: '...', getList: listHandler },
])Context Data Format
The ContextData object returned by providers:
| Field | Required | Description |
|-------|----------|-------------|
| content | Yes | Content injected into conversation |
| id | No | Resource ID (for getById scenario) |
| title | No | Display title |
| summary | No | UI preview text |
| version | No | Change detection (defaults to content hash) |
| type | No | Resource type for display styling |
| metadata | No | Extra data accessible in templates |
| template | No | Custom Mustache template: "# {{title}}\n\n{{content}}" |
Session Resources
Session resources are temporary context your app pushes to Sanqian. Unlike context providers (pull-based), session resources are push-based: your app decides when to send data.
They're visible in all Chat UI instances and persist until your app disconnects or removes them.
// Push a resource
const stored = await sdk.pushResource({
title: 'Current Note',
content: '<note>\n# My Note\nContent here...\n</note>',
summary: 'My Note - 2024-01-15',
icon: '📝',
type: 'note',
})
console.log(stored.fullId) // "my-notes-app:abc123"
// Remove a specific resource
await sdk.removeResource('my-notes-app:abc123')
// Clear all your app's resources
await sdk.clearResources()
// Get local cache (no server round-trip)
const resources = sdk.getSessionResources()
// Fetch from server (optionally filtered by agent)
const serverResources = await sdk.fetchSessionResources('notes-assistant')Attach Session Resources to Chat
for await (const event of sdk.chatStream('agent', messages, {
sessionResources: ['my-notes-app:abc123'], // Include specific resources
})) {
// ...
}Listen for Removal
When a user removes a session resource from the Chat UI:
sdk.on('resourceRemoved', (resourceId) => {
console.log(`User removed: ${resourceId}`)
})Capability Discovery
Query Sanqian's full capability registry: tools, skills, agents, and context providers.
// List all tools
const tools = await sdk.listTools()
// List by source
const builtinTools = await sdk.listTools('builtin')
const sdkTools = await sdk.listTools('sdk')
const mcpTools = await sdk.listTools('mcp')
// List skills
const skills = await sdk.listSkills()
// List all available agents (not just your own)
const agents = await sdk.listAvailableAgents()
// Generic listing with filters
const caps = await sdk.listCapabilities({
type: 'tool', // 'tool' | 'skill' | 'agent' | 'context' | 'all'
source: 'builtin', // Filter by source
category: 'file', // Filter by category (tools only)
})
// Semantic search (BM25 + Vector hybrid)
const results = await sdk.searchCapabilities('file operations', {
type: 'tool',
limit: 5,
})
for (const r of results) {
console.log(`${r.capability.id}: score=${r.score}`)
console.log(` How to use: ${r.howToUse}`)
}Embedding & Rerank Config
Reuse the embedding and rerank models configured in Sanqian. Useful for apps that need vector search or document ranking without managing their own API keys.
// Embedding
const embedding = await sdk.getEmbeddingConfig()
if (embedding.available) {
// Use embedding.apiUrl, embedding.apiKey, embedding.modelName, embedding.dimensions
}
// Rerank
const rerank = await sdk.getRerankConfig()
if (rerank.available) {
// Use rerank.apiUrl, rerank.apiKey, rerank.modelName
}Messaging Channels API
Channels methods in the SDK use Sanqian HTTP endpoints with X-App-Token auth. They only require runtime connection info and do not require WebSocket registration to be completed first.
// Useful when building URLs for local HTTP integrations
const baseUrl = sdk.getBaseUrl() // e.g. http://127.0.0.1:8765
// Fetch config schema for setup forms
const schema = await sdk.getChannelConfigSchema('telegram')
// Validate/probe config before creating an account
const probe = await sdk.probeChannelConfig({
channel_type: 'telegram',
config: { bot_token: process.env.TELEGRAM_BOT_TOKEN }
})
if (!probe.ok) {
console.error(probe.error)
}This section only shows the newly added methods. Full account/binding/message operations are available via listChannelAccounts, createChannelAccount, listChannelBindings, sendChannelMessage, and related methods.
Connection
Connection Lifecycle
The SDK manages connection automatically:
- Constructor: Reads
~/.sanqian/runtime/connection.jsonand watches for changes - Auto-connect: When connection.json appears (Sanqian starts), SDK connects automatically
- Auto-launch: If
autoLaunchSanqian: true(default), SDK starts Sanqian if not running - Registration: After WebSocket connects, SDK registers app name, tools, and context providers
- Heartbeat: 30-second interval to detect dead connections
- Auto-reconnect: Exponential backoff (500ms to 5s) with jitter when connection drops
connection.json appears -> WebSocket connect -> Register -> Heartbeat
^ |
| Reconnect (backoff) v
+<------------------ Disconnect <-------- Heartbeat timeoutReconnect Control
Auto-reconnect is reference-counted. Enable it when your UI needs a persistent connection:
// Chat panel opens - request persistent connection
sdk.acquireReconnect()
// Chat panel closes - release
sdk.releaseReconnect()
// When refCount reaches 0, auto-reconnect stopsConnection State
const state = sdk.getState()
// { connected: boolean, registering: boolean, registered: boolean,
// lastError?: Error, reconnectAttempts: number }
sdk.isConnected() // true when connected AND registeredEvents
sdk.on('connected', () => { /* WebSocket opened */ })
sdk.on('disconnected', (reason) => { /* Connection lost */ })
sdk.on('registered', () => { /* Tools and contexts registered */ })
sdk.on('error', (error) => { /* Connection error */ })
sdk.on('tool_call', ({ name, arguments }) => { /* Tool invoked (for logging) */ })
sdk.on('resourcePushed', (resource) => { /* Session resource pushed */ })
sdk.on('resourceRemoved', (resourceId) => { /* Session resource removed by user */ })
sdk.on('resourcesCleared', (appName) => { /* All resources cleared */ })
// One-time listener
sdk.once('registered', () => { /* ... */ })
// Remove listeners
sdk.off('connected', handler)
sdk.removeAllListeners() // All events
sdk.removeAllListeners('error') // Specific eventManual Connection
// Connect (usually not needed - SDK auto-connects)
await sdk.connect()
// Disconnect
await sdk.disconnect()Launched by Sanqian
When Sanqian launches your app (via launchCommand), it sets SANQIAN_LAUNCHED=1. The SDK detects this and connects immediately without auto-reconnect (Sanqian manages the lifecycle).
SDKConfig Options
const sdk = new SanqianSDK({
// Required
appName: 'my-app',
appVersion: '1.0.0',
tools: [],
// Display
displayName: 'My App', // Shown in Sanqian UI
// Launch
launchCommand: '/path/to/app', // For Sanqian to start your app
metadata: { browser: 'chrome' }, // App metadata stored with registration
// Context
contexts: [], // Context providers (see above)
// Timeouts
reconnectInterval: 5000, // Reconnect interval (ms, default: 5000)
heartbeatInterval: 30000, // Heartbeat interval (ms, default: 30000)
toolExecutionTimeout: 30000, // Tool timeout (ms, default: 30000)
connectionFileReconnectMaxDurationMs: 300000, // File-watch reconnect max duration (ms, default: 5min)
connectionFileReconnectJitterMs: 300, // Extra random delay per retry (ms, default: 300)
// Auto-launch
autoLaunchSanqian: true, // Launch Sanqian if not running (default: true)
sanqianPath: '/path/to/Sanqian', // Custom executable path (optional)
// Debug
debug: false, // Console logging (default: false)
// Browser mode (see Browser Build section)
connectionInfo: undefined, // Pre-configured connection (skips file discovery)
})Browser Build
For browser environments (extensions, web apps, Office Add-ins), use the browser-specific import:
import { SanqianSDK } from '@yushaw/sanqian-sdk/browser'The browser build:
- Uses native
WebSocket(no Node.jswsdependency) - Requires
connectionInfoin config (no filesystem access for discovery) - Does not support
autoLaunchSanqianor connection.json file watching - Supports all other features: tools, agents, chat, context providers, session resources, HITL, capability discovery
import { SanqianSDK } from '@yushaw/sanqian-sdk/browser'
const sdk = new SanqianSDK({
appName: 'my-extension',
appVersion: '1.0.0',
connectionInfo: {
port: 38765,
token: 'your-token',
ws_path: '/ws/apps',
version: 1,
pid: 0,
started_at: '',
},
tools: [/* ... */]
})
await sdk.connect()HTTP API Reference
For simple integrations without the SDK. Base URL: http://localhost:{PORT} (port from ~/.sanqian/runtime/api.port).
Chat
POST /api/agents/{agent_id}/chatRequest body:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| messages | ChatMessage[] | Yes | Messages to send |
| stream | boolean | No | Enable SSE streaming (default: true) |
| conversation_id | string | No | Continue existing conversation |
Non-streaming response (stream: false):
{
"message": { "role": "assistant", "content": "..." },
"conversation_id": "abc123"
}Streaming response (stream: true, SSE):
data: {"type": "text", "content": "Hello"}
data: {"type": "text", "content": " world"}
data: {"type": "tool_call", "tool_call": {...}}
data: {"type": "tool_result", "tool_result": {...}}
data: {"type": "done", "conversation_id": "abc123"}List Agents
GET /api/agentsReturns all available agents:
[
{ "id": "default", "name": "Default", "description": "..." },
{ "id": "coding", "name": "Coding", "description": "..." }
]Get Agent
GET /api/agents/{agent_id}Examples
Python:
import requests, json
port = open('~/.sanqian/runtime/api.port').read().strip()
# Non-streaming
r = requests.post(f'http://localhost:{port}/api/agents/default/chat', json={
'messages': [{'role': 'user', 'content': 'Hello'}],
'stream': False,
})
print(r.json()['message']['content'])
# Streaming
r = requests.post(f'http://localhost:{port}/api/agents/default/chat', json={
'messages': [{'role': 'user', 'content': 'Hello'}],
'stream': True,
}, stream=True)
for line in r.iter_lines():
if line.startswith(b'data: '):
event = json.loads(line[6:])
if event['type'] == 'text':
print(event['content'], end='', flush=True)JavaScript (no SDK):
const port = require('fs').readFileSync(
require('os').homedir() + '/.sanqian/runtime/api.port', 'utf8'
).trim()
const response = await fetch(`http://localhost:${port}/api/agents/default/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [{ role: 'user', content: 'Hello' }],
stream: false,
}),
})
const data = await response.json()
console.log(data.message.content)Built-in Tools Reference
These tools are available to all agents. Use tool names in agent config to enable specific ones, or ["*"] for all.
File Operations
| Tool | Description |
|------|-------------|
| read_file | Read files from workspace |
| write_file | Write files to workspace |
| edit_file | Edit files with precise string replacement |
| delete_file | Delete files from workspace |
| list_files | List files in workspace directory |
| find_files | Find files by name pattern (glob) |
| search_file | Search for content within files |
| grep_content | Cross-file content search with regex |
Web & Search
| Tool | Description |
|------|-------------|
| web_search | Search the web (Google Custom Search) |
| fetch_web | Fetch web pages and convert to markdown |
Execution
| Tool | Description |
|------|-------------|
| run_bash_command | Execute shell commands in workspace (sandboxed) |
Memory
| Tool | Description |
|------|-------------|
| search_memory | Search user memories by semantic similarity |
| save_memory | Save a new memory for the user |
| list_memories | List all user memories with optional filtering |
Task & Agent
| Tool | Description |
|------|-------------|
| todo_write | Create and update task list |
| task | Delegate a task to another agent |
| search_capability | Search available tools, skills, and agents |
| ask_human | Ask user for input when additional information is needed |
Vision & Image
| Tool | Description |
|------|-------------|
| vision_analyze | Analyze images using vision model |
| generate_image | Generate images from text |
macOS Apple Integration (macOS only)
| Tool | Description |
|------|-------------|
| calendar_search / calendar_create / calendar_delete | Calendar operations |
| notes_search / notes_create / notes_delete | Notes operations |
| reminders_search / reminders_create / reminders_complete / reminders_delete | Reminders operations |
| contacts_search | Search contacts |
FAQ
How do I get the agent_id for chat?
Use the List Agents API (GET /api/agents) or sdk.listAgents(). Common built-in IDs: default, coding.
When to use HTTP API vs SDK? HTTP API for simple chat from any language. SDK when you need tools, agents, context providers, or session resources.
Are SDK conversations visible in Sanqian? Yes. Conversations created via SDK appear in Sanqian's conversation list.
What about tool naming conflicts?
SDK tools are namespaced: appName:toolName. No conflicts between apps.
Does the agent auto-reconnect after Sanqian restarts?
Yes. The Node.js SDK watches connection.json. When Sanqian restarts and writes a new file, SDK reconnects automatically.
What happens if Sanqian is not running when my app starts?
With autoLaunchSanqian: true (default), SDK launches Sanqian in tray mode. Otherwise, SDK watches for connection.json and connects when Sanqian starts later.
Error Codes
| Code | Description |
|------|-------------|
| 400 | Invalid request parameters |
| 404 | Agent or conversation not found |
| 429 | Rate limited |
| 500 | Internal server error |
中文
Sanqian 提供两种外部集成方式:
| 方式 | 适用场景 | 协议 | |------|----------|------| | HTTP API | 简单对话,任意语言 | REST + SSE | | SDK | 工具注册、Agent、上下文注入、深度集成 | WebSocket |
快速开始
HTTP API
# 获取端口(Sanqian 启动时写入)
PORT=$(cat ~/.sanqian/runtime/api.port)
# 与默认 Agent 对话
curl -X POST "http://localhost:$PORT/api/agents/default/chat" \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "你好"}], "stream": false}'SDK
npm install @yushaw/sanqian-sdkimport { SanqianSDK } from '@yushaw/sanqian-sdk'
const sdk = new SanqianSDK({
appName: 'my-app',
appVersion: '1.0.0',
tools: [{
name: 'greet',
description: '向用户打招呼',
parameters: {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
},
handler: async ({ name }) => `你好,${name}!`
}]
})
// SDK 会在 Sanqian 启动时自动连接(通过监听 connection.json 文件变化)。
// 如果 Sanqian 已在运行,则立即连接。
// 如果 autoLaunchSanqian 为 true(默认),在 Sanqian 未运行时会自动启动它。工具 (Tools)
工具是你的应用向 Sanqian 提供能力的主要方式。当用户提问时,AI Agent 可以调用你的工具来获取数据或执行操作。
定义工具
每个工具需要名称、描述(给 LLM 看的)、JSON Schema 参数和处理函数。
const sdk = new SanqianSDK({
appName: 'my-notes-app',
appVersion: '1.0.0',
tools: [
{
name: 'search_notes',
description: '按关键词搜索用户笔记',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '搜索关键词' },
limit: { type: 'number', description: '最大结果数', default: 10 }
},
required: ['query']
},
handler: async ({ query, limit }) => {
const results = await db.searchNotes(query, limit)
return results.map(n => ({ title: n.title, snippet: n.snippet }))
}
}
]
})工具名称会自动加上应用前缀。search_notes 在 Sanqian 中变为 my-notes-app:search_notes。
运行时更新工具
await sdk.updateTools([
{ name: 'search_notes', /* 更新后的定义 */ handler: newHandler },
{ name: 'new_tool', /* ... */ handler: newToolHandler }
])这会替换整个工具列表。新的 Agent 运行会立即使用更新后的工具。
工具可搜索性
默认情况下,工具可以通过 Sanqian 的 search_capability 工具被发现。设置 searchable: false 隐藏工具但保持可用:
{
name: 'internal_sync',
description: '内部数据同步',
searchable: false, // 可用但不可被搜索发现
parameters: { type: 'object' },
handler: async () => { /* ... */ }
}Agent
私有 Agent 是具有特定工具、技能和指令的自定义 AI 角色。它们会出现在 Sanqian 的 Agent 选择器中。
创建 Agent
const agent = await sdk.createAgent({
agent_id: 'notes-assistant',
name: '笔记助手',
description: '帮助管理和搜索笔记',
system_prompt: '你是一个笔记助手。帮助用户整理笔记。',
tools: ['search_notes', 'create_note'], // SDK 工具(自动加前缀)
skills: ['web-research'], // Sanqian 内置技能
subagents: ['*'], // 可以委派给任何 Agent
searchable: true, // 可被其他 Agent 发现
})工具名称格式
| 格式 | 示例 | 说明 |
|------|------|------|
| 短名称 | "search_notes" | 你的 SDK 工具(自动加应用前缀) |
| 完整 SDK 名称 | "other-app:tool_name" | 其他 SDK 应用的工具 |
| 内置 | "read_file", "run_bash_command" | Sanqian 内置工具 |
| MCP | "mcp_servername_toolname" | MCP 服务器工具 |
| 通配符 | ["*"] | 所有可用工具 |
子 Agent
控制你的 Agent 是否可以委派任务给其他 Agent:
{
subagents: undefined, // 不能使用 task 工具(默认)
subagents: [], // 不能使用 task 工具(显式)
subagents: ['*'], // 可以调用任何 Agent
subagents: ['agent1', 'agent2'] // 只能调用指定的 Agent
}更新和删除
// 更新特定字段(其他不变)
await sdk.updateAgent('notes-assistant', {
system_prompt: '更新后的指令...',
tools: ['search_notes', 'create_note', 'delete_note']
})
// 删除
await sdk.deleteAgent('notes-assistant')
// 列出你的 Agent
const agents = await sdk.listAgents()对话 (Chat)
向任何 Agent 发送消息并获取回复。支持流式和非流式模式。
非流式
const response = await sdk.chat('notes-assistant', [
{ role: 'user', content: '找到我关于 TypeScript 的笔记' }
])
console.log(response.message.content)流式
for await (const event of sdk.chatStream('notes-assistant', [
{ role: 'user', content: '总结我最近的笔记' }
])) {
switch (event.type) {
case 'start':
// 流开始,event.run_id 可用
break
case 'text':
process.stdout.write(event.content || '')
break
case 'thinking':
// 推理内容(DeepSeek R1、o3 等)
break
case 'tool_call':
console.log(`调用工具: ${event.tool_call?.function.name}`)
break
case 'tool_args_chunk':
// 流式工具参数(部分 JSON)
break
case 'tool_result':
console.log(`结果: ${event.success ? '成功' : event.error}`)
break
case 'done':
console.log(`\n会话: ${event.conversationId}`)
break
case 'error':
console.error(event.error)
break
}
}流事件
| 事件 | 字段 | 说明 |
|------|------|------|
| start | run_id, conversationId | 流开始 |
| text | content | AI 文本片段 |
| thinking | content | 推理内容(思考模型) |
| tool_call | tool_call | 工具调用开始 |
| tool_args_chunk | tool_call_id, tool_name, chunk | 流式工具参数 |
| tool_args | tool_call_id, tool_name, args | 完整工具参数 |
| tool_result | tool_call_id, result, success, error | 工具执行结果 |
| interrupt | interrupt_type, interrupt_payload, run_id | HITL 暂停 |
| done | conversationId, title | 流结束 |
| error | error | 发生错误 |
| cancelled | run_id | 运行被取消 |
有状态 vs 无状态
// 无状态:你自己管理消息历史
const r1 = await sdk.chat('agent', messages)
// r1.conversationId 是空字符串
// 有状态:服务器管理历史
const r1 = await sdk.chat('agent', messages, {
persistHistory: true // 创建服务端会话
})
// r1.conversationId 有值,后续使用它
const r2 = await sdk.chat('agent', [{ role: 'user', content: '继续' }], {
conversationId: r1.conversationId
})会话助手
多轮对话可以使用 Conversation 助手:
const conv = sdk.startConversation('notes-assistant')
const r1 = await conv.send('找到我的 TypeScript 笔记')
const r2 = await conv.send('总结第一个')
console.log(conv.id) // 会话 ID(首次 send 后可用)
// 流式
for await (const event of conv.sendStream('还有更多细节吗?')) {
if (event.type === 'text') process.stdout.write(event.content || '')
}
// 获取历史
const details = await conv.getDetails({ messageLimit: 50 })
// 删除
await conv.delete()取消运行
let runId: string | undefined
for await (const event of sdk.chatStream('agent', messages)) {
if (event.type === 'start') runId = event.run_id
if (shouldCancel) {
sdk.cancelRun(runId!)
break
}
}自动发现
为对话启用自动发现技能、工具或子 Agent:
const response = await sdk.chat('agent', messages, {
autoDiscoverSkills: true, // Agent 可以搜索并使用技能
autoDiscoverTools: true, // Agent 可以搜索并使用工具
autoDiscoverSubagents: true // Agent 可以委派给其他 Agent
})本地文件导入
对于 Sanqian 后端与调用方在同一台机器、且后端能直接读取本地路径的场景,先调用 uploadFile(),再把返回的 file.path 传给 chat():
const uploaded = await sdk.uploadFile('/absolute/path/report.pdf', {
conversationId,
autoIndex: true,
asyncProcess: true,
})
const response = await sdk.chat('agent', messages, {
conversationId: uploaded.conversationId,
filePaths: [uploaded.file.path],
})uploadFile(localPath) 不是远程文件传输协议。对于远程 backend 或不同机器场景,仍应使用显式字节上传能力。
人机协作 (HITL)
HITL 允许 Agent 在运行中暂停并请求用户输入。三种中断类型:
| 类型 | 用途 | 示例 |
|------|------|------|
| approval_request | 执行前审批 | "删除文件 X?" -> 批准/拒绝 |
| user_input_request | 请求用户输入 | "什么格式?" -> ["JSON", "CSV"] 或自由文本 |
| user_action_request | 用户完成外部操作 | "需要登录" -> 用户登录 -> "完成" |
处理中断
for await (const event of sdk.chatStream('agent', messages)) {
if (event.type === 'interrupt') {
const { interrupt_type, interrupt_payload, run_id } = event
if (interrupt_type === 'approval_request') {
const approved = await showApprovalDialog(interrupt_payload)
sdk.sendHitlResponse(run_id!, { approved })
}
if (interrupt_type === 'user_input_request') {
const answer = await showInputDialog(interrupt_payload)
sdk.sendHitlResponse(run_id!, { answer })
}
}
}会话管理 (Conversations)
管理服务器上存储的对话历史。
// 列出会话
const { conversations, total } = await sdk.listConversations({
agentId: 'notes-assistant',
limit: 20,
offset: 0
})
// 获取会话详情(含消息)
const detail = await sdk.getConversation(conversationId, {
includeMessages: true,
messageLimit: 50,
})
// 获取消息历史(通过 HTTP API,与主应用一致)
const history = await sdk.getMessages(conversationId, { limit: 50 })
// 删除会话
await sdk.deleteConversation(conversationId)上下文提供者 (Context Providers)
上下文提供者让你的应用向对话注入动态状态。当用户附加上下文时,Sanqian 调用你的提供者获取最新数据。
三种提供者方法,均为可选:
| 方法 | 用途 | 调用时机 |
|------|------|----------|
| getCurrent() | 获取当前状态 | 用户发送带上下文的消息 |
| getList(options) | 列出可用资源 | 用户打开 "+" 菜单浏览 |
| getById(id) | 获取特定资源 | 用户从列表中选择 |
注册上下文提供者
const sdk = new SanqianSDK({
appName: 'my-notes-app',
appVersion: '1.0.0',
tools: [/* ... */],
contexts: [
{
id: 'active-note',
name: '当前笔记',
description: '正在编辑的笔记',
getCurrent: async () => ({
content: editor.getCurrentNote().content,
title: editor.getCurrentNote().title,
type: 'note',
}),
},
{
id: 'notes',
name: '笔记库',
description: '浏览并附加笔记',
getList: async (options) => {
const notes = await db.searchNotes(options?.query || '', {
offset: options?.offset || 0,
limit: options?.limit || 20,
})
return {
items: notes.map(n => ({
id: n.id,
title: n.title,
summary: n.snippet,
type: 'note',
group: n.folder,
})),
hasMore: notes.length === (options?.limit || 20),
}
},
getById: async (id) => {
const note = await db.getNote(id)
if (!note) return null
return { id: note.id, content: note.content, title: note.title, type: 'note' }
},
}
]
})将上下文附加到 Agent
await sdk.createAgent({
agent_id: 'notes-assistant',
name: '笔记助手',
tools: ['search_notes'],
attached_contexts: ['active-note'], // 自动加应用前缀
})运行时更新上下文
await sdk.updateContexts([
{ id: 'active-note', name: '当前笔记', description: '...', getCurrent: newHandler },
])会话资源 (Session Resources)
会话资源是你的应用推送给 Sanqian 的临时上下文。与上下文提供者(拉取式)不同,会话资源是推送式的。
// 推送资源
const stored = await sdk.pushResource({
title: '当前笔记',
content: '<note>\n# 我的笔记\n内容...\n</note>',
summary: '我的笔记 - 2024-01-15',
})
console.log(stored.fullId) // "my-notes-app:abc123"
// 移除资源
await sdk.removeResource('my-notes-app:abc123')
// 清除所有资源
await sdk.clearResources()
// 本地缓存(无网络请求)
const resources = sdk.getSessionResources()
// 从服务器获取
const serverResources = await sdk.fetchSessionResources('notes-assistant')在对话中附加会话资源
for await (const event of sdk.chatStream('agent', messages, {
sessionResources: ['my-notes-app:abc123'],
})) { /* ... */ }能力发现 (Capability Discovery)
查询 Sanqian 的完整能力注册表。
// 列出工具
const tools = await sdk.listTools()
const builtinTools = await sdk.listTools('builtin')
// 列出技能
const skills = await sdk.listSkills()
// 列出所有可用 Agent
const agents = await sdk.listAvailableAgents()
// 语义搜索(BM25 + 向量混合搜索)
const results = await sdk.searchCapabilities('文件操作', {
type: 'tool',
limit: 5,
})Embedding 与 Rerank 配置
复用 Sanqian 中配置的嵌入和重排序模型。
const embedding = await sdk.getEmbeddingConfig()
if (embedding.available) {
// 使用 embedding.apiUrl, embedding.apiKey, embedding.modelName
}
const rerank = await sdk.getRerankConfig()
if (rerank.available) {
// 使用 rerank.apiUrl, rerank.apiKey, rerank.modelName
}消息渠道 API
SDK 的 channels 方法通过 Sanqian HTTP 接口调用,并自动附带 X-App-Token 鉴权。它们只依赖运行时连接信息,不要求先完成 WebSocket 注册。
// 构建本地 HTTP 集成时可直接复用
const baseUrl = sdk.getBaseUrl() // 例如 http://127.0.0.1:8765
// 获取某个渠道的配置 schema(用于设置表单)
const schema = await sdk.getChannelConfigSchema('telegram')
// 创建账号前先探测配置是否可用
const probe = await sdk.probeChannelConfig({
channel_type: 'telegram',
config: { bot_token: process.env.TELEGRAM_BOT_TOKEN }
})
if (!probe.ok) {
console.error(probe.error)
}这里仅展示新增方法。完整渠道能力可通过 listChannelAccounts、createChannelAccount、listChannelBindings、sendChannelMessage 等方法使用。
连接
连接生命周期
SDK 自动管理连接:
- 构造函数:读取
~/.sanqian/runtime/connection.json并监听变化 - 自动连接:connection.json 出现时(Sanqian 启动),SDK 自动连接
- 自动启动:
autoLaunchSanqian: true(默认),未运行时自动启动 Sanqian - 注册:WebSocket 连接后,注册应用名称、工具和上下文提供者
- 心跳:30 秒间隔检测连接状态
- 自动重连:指数退避(500ms 到 5s)带抖动
重连控制
自动重连是引用计数的:
sdk.acquireReconnect() // 聊天面板打开 - 请求持久连接
sdk.releaseReconnect() // 聊天面板关闭 - 释放连接状态
const state = sdk.getState()
// { connected, registering, registered, lastError?, reconnectAttempts }
sdk.isConnected() // true 当已连接且已注册事件
sdk.on('connected', () => { /* WebSocket 已打开 */ })
sdk.on('disconnected', (reason) => { /* 连接断开 */ })
sdk.on('registered', () => { /* 工具和上下文已注册 */ })
sdk.on('error', (error) => { /* 连接错误 */ })
sdk.on('tool_call', ({ name, arguments }) => { /* 工具被调用 */ })
sdk.on('resourcePushed', (resource) => { /* 会话资源已推送 */ })
sdk.on('resourceRemoved', (resourceId) => { /* 用户移除了会话资源 */ })
sdk.on('resourcesCleared', (appName) => { /* 所有资源已清除 */ })
sdk.once('registered', () => { /* 一次性监听 */ })
sdk.removeAllListeners() // 移除所有监听器SDKConfig 选项
const sdk = new SanqianSDK({
// 必填
appName: 'my-app',
appVersion: '1.0.0',
tools: [],
// 显示
displayName: 'My App', // Sanqian UI 中显示的名称
// 启动
launchCommand: '/path/to/app', // Sanqian 启动你的应用的命令
metadata: { browser: 'chrome' }, // 应用元数据
// 上下文
contexts: [], // 上下文提供者
// 超时
reconnectInterval: 5000, // 重连间隔(毫秒,默认 5000)
heartbeatInterval: 30000, // 心跳间隔(毫秒,默认 30000)
toolExecutionTimeout: 30000, // 工具超时(毫秒,默认 30000)
// 自动启动
autoLaunchSanqian: true, // 未运行时启动 Sanqian(默认 true)
sanqianPath: '/path/to/Sanqian', // 自定义可执行文件路径
// 调试
debug: false, // 控制台日志(默认 false)
// 浏览器模式
connectionInfo: undefined, // 预配置连接信息(跳过文件发现)
})浏览器构建 (Browser Build)
用于浏览器环境(扩展、Web 应用、Office 插件):
import { SanqianSDK } from '@yushaw/sanqian-sdk/browser'
const sdk = new SanqianSDK({
appName: 'my-extension',
appVersion: '1.0.0',
connectionInfo: {
port: 38765,
token: 'your-token',
ws_path: '/ws/apps',
version: 1,
pid: 0,
started_at: '',
},
tools: [/* ... */]
})
await sdk.connect()浏览器构建使用原生 WebSocket,需要提供 connectionInfo,不支持 autoLaunchSanqian 和 connection.json 文件监听。其他所有功能均可使用。
HTTP API 参考
简单集成无需 SDK。基础 URL:http://localhost:{PORT}(端口来自 ~/.sanqian/runtime/api.port)。
对话
POST /api/agents/{agent_id}/chat| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| messages | ChatMessage[] | 是 | 发送的消息 |
| stream | boolean | 否 | 启用 SSE 流(默认 true) |
| conversation_id | string | 否 | 继续已有会话 |
列出 Agent
GET /api/agents示例
Python:
import requests, json
port = open('~/.sanqian/runtime/api.port').read().strip()
r = requests.post(f'http://localhost:{port}/api/agents/default/chat', json={
'messages': [{'role': 'user', 'content': '你好'}],
'stream': False,
})
print(r.json()['message']['content'])JavaScript(无 SDK):
const port = require('fs').readFileSync(
require('os').homedir() + '/.sanqian/runtime/api.port', 'utf8'
).trim()
const response = await fetch(`http://localhost:${port}/api/agents/default/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [{ role: 'user', content: '你好' }],
stream: false,
}),
})
const data = await response.json()
console.log(data.message.content)内置工具参考
文件操作
| 工具 | 说明 |
|------|------|
| read_file | 读取工作区文件 |
| write_file | 写入工作区文件 |
| edit_file | 精确字符串替换编辑文件 |
| delete_file | 删除工作区文件 |
| list_files | 列出目录内容 |
| find_files | 按 glob 模式查找文件 |
| search_file | 搜索文件内容 |
| grep_content | 跨文件正则内容搜索 |
网络
| 工具 | 说明 |
|------|------|
| web_search | 网络搜索 |
| fetch_web | 获取网页并转换为 Markdown |
执行
| 工具 | 说明 |
|------|------|
| run_bash_command | 执行 Shell 命令(沙箱化) |
记忆
| 工具 | 说明 |
|------|------|
| search_memory | 按语义相似度搜索记忆 |
| save_memory | 保存新记忆 |
| list_memories | 列出所有记忆 |
任务与 Agent
| 工具 | 说明 |
|------|------|
| todo_write | 创建和更新任务列表 |
| task | 委派任务给其他 Agent |
| search_capability | 搜索可用工具/技能/Agent |
| ask_human | 需要更多信息时询问用户 |
视觉与图像
| 工具 | 说明 |
|------|------|
| vision_analyze | 图像分析 |
| generate_image | 文本生成图像 |
macOS Apple 集成(仅 macOS)
| 工具 | 说明 |
|------|------|
| calendar_search / calendar_create / calendar_delete | 日历操作 |
| notes_search / notes_create / notes_delete | 备忘录操作 |
| reminders_search / reminders_create / reminders_complete / reminders_delete | 提醒事项操作 |
| contacts_search | 搜索通讯录 |
常见问题
如何获取 agent_id?
使用 GET /api/agents 或 sdk.listAgents()。常见内置 ID:default、coding。
什么时候用 HTTP API vs SDK? HTTP API 适合任意语言的简单对话。SDK 适合需要工具、Agent、上下文提供者或会话资源的场景。
SDK 会话在 Sanqian 中可见吗? 是的。通过 SDK 创建的会话会出现在 Sanqian 的会话列表中。
Sanqian 重启后 Agent 会自动重连吗?
是的。Node.js SDK 监听 connection.json。Sanqian 重启写入新文件后,SDK 自动重连。
如果启动时 Sanqian 未运行怎么办?
autoLaunchSanqian: true(默认)时,SDK 会在托盘模式下启动 Sanqian。否则 SDK 监听 connection.json,Sanqian 启动后自动连接。
错误码
| 码 | 说明 |
|----|------|
| 400 | 请求参数无效 |
| 404 | Agent 或会话未找到 |
| 429 | 频率限制 |
| 500 | 内部服务器错误 |
Changelog
0.3.25
- Centralized HTTP request pipeline: all HTTP API calls now go through a unified
httpRequest()method that automatically injectsX-App-Tokenauth headers and standardizes error handling viaSanqianSDKError. This prevents auth header omissions and ensures consistent error types for SDK consumers. - Fixed 4 HTTP methods (
getMessages,fetchSessionResources,listCapabilities,searchCapabilities) that were missing auth headers, causing 401 errors whenConnectionTokenMiddlewareis active. - Net reduction of ~240 lines of duplicated fetch/error-handling boilerplate across Node.js and browser clients.
- Fixed browser build (
@yushaw/sanqian-sdk/browser) missing type exports:ConversationHistoryResult,ContextCapability,ResourceType,ResourceListOptions,ResourceListResult. Browser consumers can now import these types directly. - Fixed browser build missing Context Provider support: constructor registration, registration message, and incoming message handlers (
context_get_current/list/by_id) now match the Node.js build. - Fixed browser build registration missing
appKeyfield. - Unified error types: all
throw new Error()in both clients replaced withcreateSDKError()for consistentSanqianSDKErroracross the SDK surface. - Added JSON parse error handling in
httpRequest()to catch malformed server responses. - Fixed promise leak in
connectWithInfo(): if WebSocket closes or errors beforeonopenfires, the connection promise now properly rejects instead of hanging indefinitely. - Added constructor config validation:
appNamerequired,toolsmust be array, each tool must havename,description, andhandler. - Clamped timing config to sane minimums:
reconnectInterval >= 100ms,heartbeatInterval >= 1000ms,toolExecutionTimeout >= 1000ms. - Fixed
RegisterMessage.contextstype missinghas_get_current/has_get_list/has_get_by_idfields (type definition now matches what the code actually sends). - Fixed
SDKExtendedMethods.removeSessionResource->removeResourceto match the actual SDK method name. This was a real bug:packages/chatadapter was calling a non-existent method, causing session resource removal to silently no-op. - Fixed
SDKExtendedMethods.getMessagesreturn type to useConversationHistoryResultinstead of an anonymous inline type. - Added
SDKExtendedMethodstype export to browser build. - Fixed memory leak in tool execution timeout:
Promise.racewithsetTimeoutnow properly clears the timer when the handler resolves first, preventing orphaned timers. - Fixed discovery file watcher debounce accumulation: rapid
connection.jsonchanges no longer stack up multiple pending timeouts. Previous timeouts are cancelled before creating new ones, and cleanup instopWatching()also clears pending debounce timers. - Fixed colon truncation in tool/context-provider name parsing:
name.split(":")[1]only returned text between 1st and 2nd colon. Changed tosubstring(indexOf(":") + 1)to preserve everything after the first colon (8 call sites across both clients). - Removed dead code:
DiscoveryManager.buildWebSocketUrl()andbuildHttpUrl()had zero production callers (clients have their own private versions that correctly supportws_protocol/ws_host). Also removed 4 no-op test mocks of these methods inclient.test.ts. - SDK Client Deduplication Refactoring: Extracted ~2100 lines of identical code from
client.tsandclient.browser.tsintoclient.base.ts(SanqianSDKBaseabstract class). Platform-specific behavior is handled via 5 abstract methods + 1 abstract getter.client.ts(Node.js) is now ~250 lines,client.browser.ts(Browser) is ~80 lines. Zero public API changes. Fixed 3 drift bugs in the process:updateTools()in browser was missingthis.config.tools = tools(reconnect used stale tool list)disconnect()in browser was missingsessionResources.clear()(stale resource cache on reconnect)listAvailableAgents()in browser usedscope: "available"vs Node'sscope: "enabled"(unified to"enabled")
