From 0c15af90b1164d3df4a51b48b72b20f110d15dc2 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 6 Apr 2026 14:42:01 +0100 Subject: [PATCH 1/2] feat: history overwrite --- .../api/answer/services/stream_processor.py | 144 ++++++++++----- application/api/user/agents/routes.py | 30 ++++ application/api/v1/routes.py | 22 ++- application/api/v1/translator.py | 22 ++- docs/content/Agents/_meta.js | 4 + docs/content/Agents/api.mdx | 4 + docs/content/Agents/basics.mdx | 1 + docs/content/Agents/openai-compatible.mdx | 93 ++++++++++ frontend/src/agents/NewAgent.tsx | 48 +++++ frontend/src/agents/types/index.ts | 1 + .../src/agents/workflow/WorkflowBuilder.tsx | 42 +++++ frontend/src/locale/de.json | 4 +- frontend/src/locale/en.json | 4 +- frontend/src/locale/es.json | 4 +- frontend/src/locale/jp.json | 4 +- frontend/src/locale/ru.json | 4 +- frontend/src/locale/zh-TW.json | 4 +- frontend/src/locale/zh.json | 4 +- .../answer/services/test_stream_processor.py | 164 ++++++++++++++++++ tests/api/answer/test_stream_processor.py | 26 +-- tests/test_v1_translator.py | 76 +++++++- 21 files changed, 635 insertions(+), 70 deletions(-) create mode 100644 docs/content/Agents/openai-compatible.mdx diff --git a/application/api/answer/services/stream_processor.py b/application/api/answer/services/stream_processor.py index fe2d7d8c..79b6cc7b 100644 --- a/application/api/answer/services/stream_processor.py +++ b/application/api/answer/services/stream_processor.py @@ -112,6 +112,7 @@ class StreamProcessor: self._required_tool_actions: Optional[Dict[str, Set[Optional[str]]]] = None self.compressed_summary: Optional[str] = None self.compressed_summary_tokens: int = 0 + self._agent_data: Optional[Dict[str, Any]] = None def initialize(self): """Initialize all required components for processing""" @@ -359,22 +360,29 @@ class StreamProcessor: return data def _configure_source(self): - """Configure the source based on agent data""" - api_key = self.data.get("api_key") or self.agent_key + """Configure the source based on agent data. - if api_key: - agent_data = self._get_data_from_api_key(api_key) + The literal string ``"default"`` is a placeholder meaning "no + ingested source" and is normalized to an empty source so that no + retrieval is attempted. + """ + if self._agent_data: + agent_data = self._agent_data if agent_data.get("sources") and len(agent_data["sources"]) > 0: source_ids = [ - source["id"] for source in agent_data["sources"] if source.get("id") + source["id"] + for source in agent_data["sources"] + if source.get("id") and source["id"] != "default" ] if source_ids: self.source = {"active_docs": source_ids} else: self.source = {} - self.all_sources = agent_data["sources"] - elif agent_data.get("source"): + self.all_sources = [ + s for s in agent_data["sources"] if s.get("id") != "default" + ] + elif agent_data.get("source") and agent_data["source"] != "default": self.source = {"active_docs": agent_data["source"]} self.all_sources = [ { @@ -387,11 +395,24 @@ class StreamProcessor: self.all_sources = [] return if "active_docs" in self.data: - self.source = {"active_docs": self.data["active_docs"]} + active_docs = self.data["active_docs"] + if active_docs and active_docs != "default": + self.source = {"active_docs": active_docs} + else: + self.source = {} return self.source = {} self.all_sources = [] + def _has_active_docs(self) -> bool: + """Return True if a real document source is configured for retrieval.""" + active_docs = self.source.get("active_docs") if self.source else None + if not active_docs: + return False + if active_docs == "default": + return False + return True + def _resolve_agent_id(self) -> Optional[str]: """Resolve agent_id from request, then fall back to conversation context.""" request_agent_id = self.data.get("agent_id") @@ -433,48 +454,39 @@ class StreamProcessor: effective_key = self.data.get("api_key") or self.agent_key if effective_key: - data_key = self._get_data_from_api_key(effective_key) - if data_key.get("_id"): - self.agent_id = str(data_key.get("_id")) + self._agent_data = self._get_data_from_api_key(effective_key) + if self._agent_data.get("_id"): + self.agent_id = str(self._agent_data.get("_id")) self.agent_config.update( { - "prompt_id": data_key.get("prompt_id", "default"), - "agent_type": data_key.get("agent_type", settings.AGENT_NAME), + "prompt_id": self._agent_data.get("prompt_id", "default"), + "agent_type": self._agent_data.get("agent_type", settings.AGENT_NAME), "user_api_key": effective_key, - "json_schema": data_key.get("json_schema"), - "default_model_id": data_key.get("default_model_id", ""), - "models": data_key.get("models", []), + "json_schema": self._agent_data.get("json_schema"), + "default_model_id": self._agent_data.get("default_model_id", ""), + "models": self._agent_data.get("models", []), + "allow_system_prompt_override": self._agent_data.get( + "allow_system_prompt_override", False + ), } ) # Set identity context if self.data.get("api_key"): # External API key: use the key owner's identity - self.initial_user_id = data_key.get("user") - self.decoded_token = {"sub": data_key.get("user")} + self.initial_user_id = self._agent_data.get("user") + self.decoded_token = {"sub": self._agent_data.get("user")} elif self.is_shared_usage: # Shared agent: keep the caller's identity pass else: # Owner using their own agent - self.decoded_token = {"sub": data_key.get("user")} + self.decoded_token = {"sub": self._agent_data.get("user")} - if data_key.get("source"): - self.source = {"active_docs": data_key["source"]} - if data_key.get("workflow"): - self.agent_config["workflow"] = data_key["workflow"] - self.agent_config["workflow_owner"] = data_key.get("user") - if data_key.get("retriever"): - self.retriever_config["retriever_name"] = data_key["retriever"] - if data_key.get("chunks") is not None: - try: - self.retriever_config["chunks"] = int(data_key["chunks"]) - except (ValueError, TypeError): - logger.warning( - f"Invalid chunks value: {data_key['chunks']}, using default value 2" - ) - self.retriever_config["chunks"] = 2 + if self._agent_data.get("workflow"): + self.agent_config["workflow"] = self._agent_data["workflow"] + self.agent_config["workflow_owner"] = self._agent_data.get("user") else: # No API key — default/workflow configuration agent_type = settings.AGENT_NAME @@ -497,14 +509,45 @@ class StreamProcessor: ) def _configure_retriever(self): + """Assemble retriever config with precedence: request > agent > default.""" doc_token_limit = calculate_doc_token_budget(model_id=self.model_id) + # Start with defaults + retriever_name = "classic" + chunks = 2 + + # Layer agent-level config (if present) + if self._agent_data: + if self._agent_data.get("retriever"): + retriever_name = self._agent_data["retriever"] + if self._agent_data.get("chunks") is not None: + try: + chunks = int(self._agent_data["chunks"]) + except (ValueError, TypeError): + logger.warning( + f"Invalid agent chunks value: {self._agent_data['chunks']}, " + "using default value 2" + ) + + # Explicit request values win over agent config + if "retriever" in self.data: + retriever_name = self.data["retriever"] + if "chunks" in self.data: + try: + chunks = int(self.data["chunks"]) + except (ValueError, TypeError): + logger.warning( + f"Invalid request chunks value: {self.data['chunks']}, " + "using default value 2" + ) + self.retriever_config = { - "retriever_name": self.data.get("retriever", "classic"), - "chunks": int(self.data.get("chunks", 2)), + "retriever_name": retriever_name, + "chunks": chunks, "doc_token_limit": doc_token_limit, } + # isNoneDoc without an API key forces no retrieval api_key = self.data.get("api_key") or self.agent_key if not api_key and "isNoneDoc" in self.data and self.data["isNoneDoc"]: self.retriever_config["chunks"] = 0 @@ -528,6 +571,9 @@ class StreamProcessor: if self.data.get("isNoneDoc", False) and not self.agent_id: logger.info("Pre-fetch skipped: isNoneDoc=True") return None, None + if not self._has_active_docs(): + logger.info("Pre-fetch skipped: no active docs configured") + return None, None try: retriever = self.create_retriever() logger.info( @@ -910,15 +956,23 @@ class StreamProcessor: raw_prompt = get_prompt(prompt_id, self.prompts_collection) self._prompt_content = raw_prompt - rendered_prompt = self.prompt_renderer.render_prompt( - prompt_content=raw_prompt, - user_id=self.initial_user_id, - request_id=self.data.get("request_id"), - passthrough_data=self.data.get("passthrough"), - docs=docs, - docs_together=docs_together, - tools_data=tools_data, - ) + # Allow API callers to override the system prompt when the agent + # has opted in via allow_system_prompt_override. + if ( + self.agent_config.get("allow_system_prompt_override", False) + and self.data.get("system_prompt_override") + ): + rendered_prompt = self.data["system_prompt_override"] + else: + rendered_prompt = self.prompt_renderer.render_prompt( + prompt_content=raw_prompt, + user_id=self.initial_user_id, + request_id=self.data.get("request_id"), + passthrough_data=self.data.get("passthrough"), + docs=docs, + docs_together=docs_together, + tools_data=tools_data, + ) provider = ( get_provider_from_model_id(self.model_id) diff --git a/application/api/user/agents/routes.py b/application/api/user/agents/routes.py index 296b8ea0..7d3dc93a 100644 --- a/application/api/user/agents/routes.py +++ b/application/api/user/agents/routes.py @@ -73,6 +73,7 @@ AGENT_TYPE_SCHEMAS = { "token_limit", "limited_request_mode", "request_limit", + "allow_system_prompt_override", "createdAt", "updatedAt", "lastUsedAt", @@ -96,6 +97,7 @@ AGENT_TYPE_SCHEMAS = { "token_limit", "limited_request_mode", "request_limit", + "allow_system_prompt_override", "createdAt", "updatedAt", "lastUsedAt", @@ -220,6 +222,12 @@ def build_agent_document( base_doc["request_limit"] = int( data.get("request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"]) ) + if "allow_system_prompt_override" in allowed_fields: + base_doc["allow_system_prompt_override"] = ( + data.get("allow_system_prompt_override") == "True" + if isinstance(data.get("allow_system_prompt_override"), str) + else bool(data.get("allow_system_prompt_override", False)) + ) return {k: v for k, v in base_doc.items() if k in allowed_fields} @@ -292,6 +300,9 @@ class GetAgent(Resource): "default_model_id": agent.get("default_model_id", ""), "folder_id": agent.get("folder_id"), "workflow": agent.get("workflow"), + "allow_system_prompt_override": agent.get( + "allow_system_prompt_override", False + ), } return make_response(jsonify(data), 200) except Exception as e: @@ -373,6 +384,9 @@ class GetAgents(Resource): "default_model_id": agent.get("default_model_id", ""), "folder_id": agent.get("folder_id"), "workflow": agent.get("workflow"), + "allow_system_prompt_override": agent.get( + "allow_system_prompt_override", False + ), } for agent in agents if "source" in agent @@ -450,6 +464,10 @@ class CreateAgent(Resource): "folder_id": fields.String( required=False, description="Folder ID to organize the agent" ), + "allow_system_prompt_override": fields.Boolean( + required=False, + description="Allow API callers to override the system prompt via the v1 endpoint", + ), }, ) @@ -674,6 +692,10 @@ class UpdateAgent(Resource): "folder_id": fields.String( required=False, description="Folder ID to organize the agent" ), + "allow_system_prompt_override": fields.Boolean( + required=False, + description="Allow API callers to override the system prompt via the v1 endpoint", + ), }, ) @@ -765,6 +787,7 @@ class UpdateAgent(Resource): "default_model_id", "folder_id", "workflow", + "allow_system_prompt_override", ] for field in allowed_fields: @@ -983,6 +1006,13 @@ class UpdateAgent(Resource): if workflow_error: return workflow_error update_fields[field] = workflow_id + elif field == "allow_system_prompt_override": + raw_value = data.get("allow_system_prompt_override", False) + update_fields[field] = ( + raw_value == "True" + if isinstance(raw_value, str) + else bool(raw_value) + ) else: value = data[field] if field in ["name", "description", "prompt_id", "agent_type"]: diff --git a/application/api/v1/routes.py b/application/api/v1/routes.py index 4d5a513b..8b1be646 100644 --- a/application/api/v1/routes.py +++ b/application/api/v1/routes.py @@ -138,10 +138,18 @@ def chat_completions(): if usage_error: return usage_error + should_save_conversation = bool(internal_data.get("save_conversation", False)) + if is_stream: return Response( _stream_response( - helper, question, agent, processor, model_name, continuation + helper, + question, + agent, + processor, + model_name, + continuation, + should_save_conversation, ), mimetype="text/event-stream", headers={ @@ -151,7 +159,13 @@ def chat_completions(): ) else: return _non_stream_response( - helper, question, agent, processor, model_name, continuation + helper, + question, + agent, + processor, + model_name, + continuation, + should_save_conversation, ) except ValueError as e: @@ -181,6 +195,7 @@ def _stream_response( processor: StreamProcessor, model_name: str, continuation: Optional[Dict], + should_save_conversation: bool, ) -> Generator[str, None, None]: """Generate translated SSE chunks for streaming response.""" completion_id = f"chatcmpl-{int(time.time())}" @@ -193,6 +208,7 @@ def _stream_response( decoded_token=processor.decoded_token, agent_id=processor.agent_id, model_id=processor.model_id, + should_save_conversation=should_save_conversation, _continuation=continuation, ) @@ -225,6 +241,7 @@ def _non_stream_response( processor: StreamProcessor, model_name: str, continuation: Optional[Dict], + should_save_conversation: bool, ) -> Response: """Collect full response and return as single JSON.""" stream = helper.complete_stream( @@ -235,6 +252,7 @@ def _non_stream_response( decoded_token=processor.decoded_token, agent_id=processor.agent_id, model_id=processor.model_id, + should_save_conversation=should_save_conversation, _continuation=continuation, ) diff --git a/application/api/v1/translator.py b/application/api/v1/translator.py index 3d3c64fe..5330a029 100644 --- a/application/api/v1/translator.py +++ b/application/api/v1/translator.py @@ -80,6 +80,17 @@ def extract_conversation_id(messages: List[Dict]) -> Optional[str]: return None +def extract_system_prompt(messages: List[Dict]) -> Optional[str]: + """Extract the first system message content from the messages array. + + Returns None if no system message is present. + """ + for msg in messages: + if msg.get("role") == "system": + return msg.get("content", "") + return None + + def convert_history(messages: List[Dict]) -> List[Dict]: """Convert chat completions messages array to DocsGPT history format. @@ -148,20 +159,27 @@ def translate_request( break history = convert_history(messages) + system_prompt_override = extract_system_prompt(messages) + + docsgpt = data.get("docsgpt", {}) result = { "question": question, "api_key": api_key, "history": json.dumps(history), - "save_conversation": True, + # Conversations are NOT persisted by default on the v1 endpoint. + # Callers opt in via ``docsgpt.save_conversation: true``. + "save_conversation": bool(docsgpt.get("save_conversation", False)), } + if system_prompt_override is not None: + result["system_prompt_override"] = system_prompt_override + # Client tools if data.get("tools"): result["client_tools"] = data["tools"] # DocsGPT extensions - docsgpt = data.get("docsgpt", {}) if docsgpt.get("attachments"): result["attachments"] = docsgpt["attachments"] diff --git a/docs/content/Agents/_meta.js b/docs/content/Agents/_meta.js index c38c6e36..61900068 100644 --- a/docs/content/Agents/_meta.js +++ b/docs/content/Agents/_meta.js @@ -7,6 +7,10 @@ export default { "title": "🔌 Agent API", "href": "/Agents/api" }, + "openai-compatible": { + "title": "🔄 OpenAI-Compatible API", + "href": "/Agents/openai-compatible" + }, "webhooks": { "title": "🪝 Agent Webhooks", "href": "/Agents/webhooks" diff --git a/docs/content/Agents/api.mdx b/docs/content/Agents/api.mdx index 5b1313dd..ad77a69a 100644 --- a/docs/content/Agents/api.mdx +++ b/docs/content/Agents/api.mdx @@ -15,6 +15,10 @@ DocsGPT Agents can be accessed programmatically through API endpoints. This page When you use an agent `api_key`, DocsGPT loads that agent's configuration automatically (prompt, tools, sources, default model). You usually only need to send `question` and `api_key`. + +Looking to connect an existing OpenAI-compatible client (opencode, aider, the OpenAI SDKs, etc.) to a DocsGPT Agent? Use the [OpenAI-Compatible Chat Completions API](/Agents/openai-compatible) — it speaks the standard chat completions protocol so no adapter code is required. + + ## Base URL diff --git a/docs/content/Agents/basics.mdx b/docs/content/Agents/basics.mdx index 3ad407df..741df77b 100644 --- a/docs/content/Agents/basics.mdx +++ b/docs/content/Agents/basics.mdx @@ -111,6 +111,7 @@ Once an agent is created, you can: * Modify any of its configuration settings (name, description, source, prompt, tools, type). * **Generate a Public Link:** From the edit screen, you can create a shareable public link that allows others to import and use your agent. * **Get a Webhook URL:** You can also obtain a Webhook URL for the agent. This allows external applications or services to trigger the agent and receive responses programmatically, enabling powerful integrations and automations. +* **Use it via API:** Every agent exposes an API key that can be used with the native [Agent API](/Agents/api) or the [OpenAI-Compatible API](/Agents/openai-compatible) so you can drop DocsGPT Agents into any tool that already speaks the chat completions protocol. ## Seeding Premade Agents from YAML diff --git a/docs/content/Agents/openai-compatible.mdx b/docs/content/Agents/openai-compatible.mdx new file mode 100644 index 00000000..0994ec59 --- /dev/null +++ b/docs/content/Agents/openai-compatible.mdx @@ -0,0 +1,93 @@ +--- +title: OpenAI-Compatible API +description: Connect any OpenAI-compatible client to DocsGPT Agents via /v1/chat/completions. +--- + +import { Callout, Tabs } from 'nextra/components'; + +# OpenAI-Compatible API + +DocsGPT exposes `/v1/chat/completions` following the standard chat completions protocol. Point any compatible client — **opencode**, **Aider**, **LibreChat** or the OpenAI SDKs — at your DocsGPT Agent by changing only the base URL and API key. + +## Quick Start + + + + ```python + from openai import OpenAI + + client = OpenAI( + base_url="http://localhost:7091/v1", # or https://gptcloud.arc53.com/v1 + api_key="your_agent_api_key", + ) + + response = client.chat.completions.create( + model="docsgpt-agent", + messages=[{"role": "user", "content": "Summarize our refund policy"}], + ) + print(response.choices[0].message.content) + ``` + + + ```bash + curl -X POST http://localhost:7091/v1/chat/completions \ + -H "Authorization: Bearer your_agent_api_key" \ + -H "Content-Type: application/json" \ + -d '{"model":"docsgpt-agent","messages":[{"role":"user","content":"Summarize our refund policy"}]}' + ``` + + + +The `model` field is accepted but ignored — the agent bound to your API key determines the model. The agent's prompt, sources, tools, and default model are loaded automatically. + +## Base URL & Auth + +| Environment | Base URL | +| --- | --- | +| Local | `http://localhost:7091/v1` | +| Cloud | `https://gptcloud.arc53.com/v1` | + +Authenticate with `Authorization: Bearer `. + +## Endpoints + +| Method | Path | Description | +| --- | --- | --- | +| `POST` | `/v1/chat/completions` | Chat request (streaming or non-streaming) | +| `GET` | `/v1/models` | List agents available to your key | + +## Streaming + +Set `"stream": true`. You'll receive SSE chunks with `choices[0].delta.content`. DocsGPT-specific events (sources, tool calls) arrive as extra frames with a `docsgpt` key — standard clients ignore them. + +```python +stream = client.chat.completions.create( + model="docsgpt-agent", + stream=True, + messages=[{"role": "user", "content": "Explain vector search"}], +) +for chunk in stream: + print(chunk.choices[0].delta.content or "", end="", flush=True) +``` + +## System Prompt Override + +System messages are **dropped by default** — the agent's configured prompt is used. To allow callers to override it, enable **Allow prompt override** in the agent's Advanced settings. + + +When an override is active, the agent's prompt template is replaced wholesale — template variables like `{summaries}` are not substituted. + + +## Conversation Persistence + +Conversations are **not persisted by default** (stateless, like most OpenAI clients expect). Opt in per request: + +```json +{ "docsgpt": { "save_conversation": true } } +``` + +The response will include `docsgpt.conversation_id`. + +## When to Use Native Endpoints Instead + +Use [`/api/answer` or `/stream`](/Agents/api) if you need server-side attachments, `passthrough` template variables, explicit `conversation_id` reuse, or persistence by default. diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index 133ffc7c..938b2e4f 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -73,6 +73,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { token_limit: undefined, limited_request_mode: false, request_limit: undefined, + allow_system_prompt_override: false, models: [], default_model_id: '', }); @@ -241,6 +242,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { formData.append('request_limit', '0'); } + formData.append( + 'allow_system_prompt_override', + agent.allow_system_prompt_override ? 'True' : 'False', + ); + if (imageFile) formData.append('image', imageFile); if (agent.tools && agent.tools.length > 0) @@ -361,6 +367,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { formData.append('request_limit', '0'); } + formData.append( + 'allow_system_prompt_override', + agent.allow_system_prompt_override ? 'True' : 'False', + ); + if (agent.models && agent.models.length > 0) { formData.append('models', JSON.stringify(agent.models)); } @@ -1266,6 +1277,43 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { }`} /> + +
+
+
+

+ {t('agents.form.advanced.systemPromptOverride')} +

+

+ {t( + 'agents.form.advanced.systemPromptOverrideDescription', + )} +

+
+ +
+
)} diff --git a/frontend/src/agents/types/index.ts b/frontend/src/agents/types/index.ts index 6e732401..12e27c8a 100644 --- a/frontend/src/agents/types/index.ts +++ b/frontend/src/agents/types/index.ts @@ -36,6 +36,7 @@ export type Agent = { default_model_id?: string; folder_id?: string; workflow?: string; + allow_system_prompt_override?: boolean; }; export type AgentFolder = { diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx index 2024ee7c..abdddf46 100644 --- a/frontend/src/agents/workflow/WorkflowBuilder.tsx +++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx @@ -1142,6 +1142,10 @@ function WorkflowBuilderInner() { workflowDescription || `Workflow agent: ${workflowName}`, ); agentFormData.append('status', 'published'); + agentFormData.append( + 'allow_system_prompt_override', + currentAgent.allow_system_prompt_override ? 'True' : 'False', + ); if (imageFile) { agentFormData.append('image', imageFile); } @@ -1203,6 +1207,10 @@ function WorkflowBuilderInner() { agentFormData.append('agent_type', 'workflow'); agentFormData.append('status', 'published'); agentFormData.append('workflow', savedWorkflowId || ''); + agentFormData.append( + 'allow_system_prompt_override', + currentAgent.allow_system_prompt_override ? 'True' : 'False', + ); if (imageFile) { agentFormData.append('image', imageFile); } @@ -1454,6 +1462,40 @@ function WorkflowBuilderInner() { Image updates are included the next time you save.

+
+
+
+ +

+ Let v1 API callers replace the system prompt +

+
+ +
+