From 2a856fbc19a1030f9dd8c5ebbd72b722cba26ed8 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 21 Apr 2026 16:03:10 +0100 Subject: [PATCH 1/2] Allow chat history to be sent to AI engine (#6128) # Description of Changes Add an extra parameter to every agent to receive the conversation history in addition to the current message. This will make it possible to answer followup questions from the AI without needing to give full context in your message. --- .../model/api/ai/AiConversationMessage.java | 22 ++++++++++++++++++ .../model/api/ai/AiWorkflowRequest.java | 7 ++++++ .../service/AiWorkflowService.java | 7 ++++++ engine/src/stirling/agents/orchestrator.py | 23 ++++++++++++++++--- engine/src/stirling/agents/pdf_edit.py | 2 ++ engine/src/stirling/agents/pdf_questions.py | 9 +++++++- engine/src/stirling/agents/user_spec.py | 20 ++++++++-------- engine/src/stirling/contracts/__init__.py | 2 ++ engine/src/stirling/contracts/common.py | 6 +++++ engine/src/stirling/contracts/orchestrator.py | 9 +++++++- engine/src/stirling/contracts/pdf_edit.py | 3 ++- .../src/stirling/contracts/pdf_questions.py | 9 +++++++- engine/tests/test_user_spec_agent.py | 8 +++++-- .../components/chat/ChatContext.tsx | 8 +++++++ 14 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiConversationMessage.java diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiConversationMessage.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiConversationMessage.java new file mode 100644 index 0000000000..32a811302c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiConversationMessage.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.model.api.ai; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import lombok.Data; + +@Data +@Schema(description = "A prior message in the chat conversation") +public class AiConversationMessage { + + @NotNull + @NotBlank + @Schema(description = "The role of the message sender", example = "user") + private String role; + + @NotNull + @Schema(description = "The content of the message") + private String content; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java index 22228d2aa3..da327177c4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.model.api.ai; +import java.util.ArrayList; import java.util.List; import io.swagger.v3.oas.annotations.media.Schema; @@ -20,4 +21,10 @@ public class AiWorkflowRequest { @NotBlank @Schema(description = "The user message to orchestrate", example = "Summarise these documents") private String userMessage; + + @Schema( + description = + "Prior chat messages exchanged between the user and the assistant, ordered" + + " oldest-first. Excludes the current userMessage.") + private List conversationHistory = new ArrayList<>(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java index 0d02a44381..14bc56ed98 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java @@ -34,6 +34,7 @@ import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.ZipExtractionUtils; +import stirling.software.proprietary.model.api.ai.AiConversationMessage; import stirling.software.proprietary.model.api.ai.AiWorkflowFileInput; import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest; import stirling.software.proprietary.model.api.ai.AiWorkflowOutcome; @@ -92,6 +93,10 @@ public class AiWorkflowService { WorkflowTurnRequest initialRequest = new WorkflowTurnRequest(); initialRequest.setUserMessage(request.getUserMessage().trim()); initialRequest.setFileNames(new ArrayList<>(filesByName.keySet())); + initialRequest.setConversationHistory( + request.getConversationHistory() == null + ? new ArrayList<>() + : new ArrayList<>(request.getConversationHistory())); listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING)); @@ -183,6 +188,7 @@ public class AiWorkflowService { WorkflowTurnRequest nextRequest = new WorkflowTurnRequest(); nextRequest.setUserMessage(request.getUserMessage()); nextRequest.setFileNames(request.getFileNames()); + nextRequest.setConversationHistory(request.getConversationHistory()); nextRequest.setArtifacts(pdfContentExtractor.buildArtifacts(contentResults)); nextRequest.setResumeWith(response.getResumeWith()); return new WorkflowState.Pending(nextRequest); @@ -415,6 +421,7 @@ public class AiWorkflowService { private static class WorkflowTurnRequest { private String userMessage; private List fileNames = new ArrayList<>(); + private List conversationHistory = new ArrayList<>(); private List artifacts = new ArrayList<>(); private String resumeWith; } diff --git a/engine/src/stirling/agents/orchestrator.py b/engine/src/stirling/agents/orchestrator.py index e4c17d0059..c2537f78ce 100644 --- a/engine/src/stirling/agents/orchestrator.py +++ b/engine/src/stirling/agents/orchestrator.py @@ -23,6 +23,7 @@ from stirling.contracts import ( SupportedCapability, ToolOperationStep, UnsupportedCapabilityResponse, + format_conversation_history, ) from stirling.contracts.pdf_edit import EditPlanResponse from stirling.models.agent_tool_models import AgentToolId, MathAuditorAgentParams @@ -117,7 +118,11 @@ class OrchestratorAgent: async def _run_pdf_edit(self, request: OrchestratorRequest) -> PdfEditResponse: return await PdfEditAgent(self.runtime).handle( - PdfEditRequest(user_message=request.user_message, file_names=request.file_names) + PdfEditRequest( + user_message=request.user_message, + file_names=request.file_names, + conversation_history=request.conversation_history, + ) ) async def delegate_pdf_question(self, ctx: RunContext[OrchestratorDeps]) -> PdfQuestionResponse: @@ -130,6 +135,7 @@ class OrchestratorAgent: question=request.user_message, file_names=request.file_names, page_text=extracted_text.files if extracted_text is not None else [], + conversation_history=request.conversation_history, ) ) @@ -137,7 +143,12 @@ class OrchestratorAgent: return await self._run_agent_draft(ctx.deps.request) async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse: - return await UserSpecAgent(self.runtime).draft(AgentDraftRequest(user_message=request.user_message)) + return await UserSpecAgent(self.runtime).draft( + AgentDraftRequest( + user_message=request.user_message, + conversation_history=request.conversation_history, + ) + ) async def math_auditor_agent(self, ctx: RunContext[OrchestratorDeps]) -> EditPlanResponse: return EditPlanResponse( @@ -167,7 +178,13 @@ class OrchestratorAgent: def _build_prompt(self, request: OrchestratorRequest) -> str: artifact_summary = self._describe_artifacts(request) file_names = ", ".join(request.file_names) if request.file_names else "Unknown files" - return f"User message: {request.user_message}\nFiles: {file_names}\nAvailable artifacts:\n{artifact_summary}" + history = format_conversation_history(request.conversation_history) + return ( + f"Conversation history:\n{history}\n" + f"User message: {request.user_message}\n" + f"Files: {file_names}\n" + f"Available artifacts:\n{artifact_summary}" + ) def _describe_artifacts(self, request: OrchestratorRequest) -> str: if not request.artifacts: diff --git a/engine/src/stirling/agents/pdf_edit.py b/engine/src/stirling/agents/pdf_edit.py index b5ad8c431d..40b1365783 100644 --- a/engine/src/stirling/agents/pdf_edit.py +++ b/engine/src/stirling/agents/pdf_edit.py @@ -13,6 +13,7 @@ from stirling.contracts import ( PdfEditRequest, PdfEditResponse, ToolOperationStep, + format_conversation_history, ) from stirling.models import OPERATIONS, ApiModel, ParamToolModel, ToolEndpoint from stirling.services import AppRuntime @@ -146,6 +147,7 @@ class PdfEditAgent: def _build_selection_prompt(self, request: PdfEditRequest) -> str: file_names = ", ".join(request.file_names) if request.file_names else "No file names were provided." return ( + f"Conversation history:\n{format_conversation_history(request.conversation_history)}\n" f"User request: {request.user_message}\n" f"Files: {file_names}\n" f"Supported operations: {self._supported_operations_prompt()}\n" diff --git a/engine/src/stirling/agents/pdf_questions.py b/engine/src/stirling/agents/pdf_questions.py index 60f7ea94c2..c646159da8 100644 --- a/engine/src/stirling/agents/pdf_questions.py +++ b/engine/src/stirling/agents/pdf_questions.py @@ -12,6 +12,7 @@ from stirling.contracts import ( PdfQuestionNotFoundResponse, PdfQuestionRequest, PdfQuestionResponse, + format_conversation_history, ) from stirling.services import AppRuntime @@ -70,7 +71,13 @@ class PdfQuestionAgent: for selection in file_text.pages ] pages = "\n\n".join(sections) - return f"Files: {file_names}\nQuestion: {request.question}\nExtracted page text:\n{pages}" + history = format_conversation_history(request.conversation_history) + return ( + f"Conversation history:\n{history}\n" + f"Files: {file_names}\n" + f"Question: {request.question}\n" + f"Extracted page text:\n{pages}" + ) def _has_page_text(self, page_text: list[ExtractedFileText]) -> bool: return any(selection.text.strip() for file_text in page_text for selection in file_text.pages) diff --git a/engine/src/stirling/agents/user_spec.py b/engine/src/stirling/agents/user_spec.py index d30b5e53d5..40a028aa1b 100644 --- a/engine/src/stirling/agents/user_spec.py +++ b/engine/src/stirling/agents/user_spec.py @@ -18,6 +18,7 @@ from stirling.contracts import ( EditClarificationRequest, EditPlanResponse, PdfEditRequest, + format_conversation_history, ) from stirling.models import ApiModel from stirling.services import AppRuntime @@ -45,14 +46,15 @@ class UserSpecAgent: ) async def draft(self, request: AgentDraftRequest) -> AgentDraftWorkflowResponse: - edit_plan = await self._build_edit_plan(request.user_message) + edit_plan = await self._build_edit_plan(request.user_message, request.conversation_history) if not isinstance(edit_plan, EditPlanResponse): return edit_plan return AgentDraftResponse(draft=await self._run_draft_agent(request, edit_plan)) async def revise(self, request: AgentRevisionRequest) -> AgentRevisionWorkflowResponse: edit_plan = await self._build_edit_plan( - f"Current objective: {request.current_draft.objective}\nRevision request: {request.user_message}" + f"Current objective: {request.current_draft.objective}\nRevision request: {request.user_message}", + request.conversation_history, ) if not isinstance(edit_plan, EditPlanResponse): return edit_plan @@ -80,7 +82,7 @@ class UserSpecAgent: def _build_draft_prompt(self, request: AgentDraftRequest, edit_plan: EditPlanResponse) -> str: return ( f"User request:\n{request.user_message}\n\n" - f"Conversation history:\n{self._format_conversation_history(request.conversation_history)}\n\n" + f"Conversation history:\n{format_conversation_history(request.conversation_history)}\n\n" f"Edit plan summary:\n{edit_plan.summary}\n\n" f"Edit plan rationale:\n{edit_plan.rationale or 'None'}\n\n" f"Edit plan steps:\n{edit_plan.model_dump_json(indent=2)}" @@ -89,20 +91,18 @@ class UserSpecAgent: def _build_revision_prompt(self, request: AgentRevisionRequest, edit_plan: EditPlanResponse) -> str: return ( f"Revision request:\n{request.user_message}\n\n" - f"Conversation history:\n{self._format_conversation_history(request.conversation_history)}\n\n" + f"Conversation history:\n{format_conversation_history(request.conversation_history)}\n\n" f"Current draft:\n{request.current_draft.model_dump_json(indent=2)}\n\n" f"Edit plan summary:\n{edit_plan.summary}\n\n" f"Edit plan rationale:\n{edit_plan.rationale or 'None'}\n\n" f"Edit plan steps:\n{edit_plan.model_dump_json(indent=2)}" ) - def _format_conversation_history(self, conversation_history: list[ConversationMessage]) -> str: - if not conversation_history: - return "None" - return "\n".join(f"- {message.role}: {message.content}" for message in conversation_history) - async def _build_edit_plan( self, user_message: str, + conversation_history: list[ConversationMessage], ) -> EditPlanResponse | EditClarificationRequest | EditCannotDoResponse: - return await self.pdf_edit_agent.handle(PdfEditRequest(user_message=user_message)) + return await self.pdf_edit_agent.handle( + PdfEditRequest(user_message=user_message, conversation_history=conversation_history) + ) diff --git a/engine/src/stirling/contracts/__init__.py b/engine/src/stirling/contracts/__init__.py index 21633c075e..6fc075b444 100644 --- a/engine/src/stirling/contracts/__init__.py +++ b/engine/src/stirling/contracts/__init__.py @@ -18,6 +18,7 @@ from .common import ( SupportedCapability, ToolOperationStep, WorkflowOutcome, + format_conversation_history, ) from .execution import ( AgentExecutionRequest, @@ -104,6 +105,7 @@ __all__ = [ "Folio", "FolioManifest", "FolioType", + "format_conversation_history", "HealthResponse", "NeedContentFileRequest", "NextExecutionAction", diff --git a/engine/src/stirling/contracts/common.py b/engine/src/stirling/contracts/common.py index 9ee8856ddb..50038d1503 100644 --- a/engine/src/stirling/contracts/common.py +++ b/engine/src/stirling/contracts/common.py @@ -91,6 +91,12 @@ class ConversationMessage(ApiModel): content: str +def format_conversation_history(conversation_history: list[ConversationMessage]) -> str: + if not conversation_history: + return "None" + return "\n".join(f"- {message.role}: {message.content}" for message in conversation_history) + + class PdfTextSelection(ApiModel): page_number: int | None = None text: str diff --git a/engine/src/stirling/contracts/orchestrator.py b/engine/src/stirling/contracts/orchestrator.py index 3fa788fe66..7598372888 100644 --- a/engine/src/stirling/contracts/orchestrator.py +++ b/engine/src/stirling/contracts/orchestrator.py @@ -7,7 +7,13 @@ from pydantic import Field from stirling.models import ApiModel from .agent_drafts import AgentDraftResponse -from .common import ArtifactKind, ExtractedFileText, SupportedCapability, WorkflowOutcome +from .common import ( + ArtifactKind, + ConversationMessage, + ExtractedFileText, + SupportedCapability, + WorkflowOutcome, +) from .execution import NextExecutionAction from .pdf_edit import PdfEditResponse from .pdf_questions import PdfQuestionResponse @@ -24,6 +30,7 @@ WorkflowArtifact = Annotated[ExtractedTextArtifact, Field(discriminator="kind")] class OrchestratorRequest(ApiModel): user_message: str file_names: list[str] + conversation_history: list[ConversationMessage] = Field(default_factory=list) artifacts: list[WorkflowArtifact] = Field(default_factory=list) resume_with: SupportedCapability | None = None diff --git a/engine/src/stirling/contracts/pdf_edit.py b/engine/src/stirling/contracts/pdf_edit.py index 2bcfe7ac6f..a7bcedfa9d 100644 --- a/engine/src/stirling/contracts/pdf_edit.py +++ b/engine/src/stirling/contracts/pdf_edit.py @@ -6,12 +6,13 @@ from pydantic import Field from stirling.models import ApiModel -from .common import ToolOperationStep, WorkflowOutcome +from .common import ConversationMessage, ToolOperationStep, WorkflowOutcome class PdfEditRequest(ApiModel): user_message: str file_names: list[str] = Field(default_factory=list) + conversation_history: list[ConversationMessage] = Field(default_factory=list) class EditPlanResponse(ApiModel): diff --git a/engine/src/stirling/contracts/pdf_questions.py b/engine/src/stirling/contracts/pdf_questions.py index 987dc9b595..4ee0d25966 100644 --- a/engine/src/stirling/contracts/pdf_questions.py +++ b/engine/src/stirling/contracts/pdf_questions.py @@ -6,13 +6,20 @@ from pydantic import Field from stirling.models import ApiModel -from .common import ExtractedFileText, PdfContentType, SupportedCapability, WorkflowOutcome +from .common import ( + ConversationMessage, + ExtractedFileText, + PdfContentType, + SupportedCapability, + WorkflowOutcome, +) class PdfQuestionRequest(ApiModel): question: str page_text: list[ExtractedFileText] = Field(default_factory=list) file_names: list[str] + conversation_history: list[ConversationMessage] = Field(default_factory=list) class PdfQuestionAnswerResponse(ApiModel): diff --git a/engine/tests/test_user_spec_agent.py b/engine/tests/test_user_spec_agent.py index 1f9de664c3..3a91fcf1fb 100644 --- a/engine/tests/test_user_spec_agent.py +++ b/engine/tests/test_user_spec_agent.py @@ -32,7 +32,9 @@ class StubUserSpecAgent(UserSpecAgent): ], ) - async def _build_edit_plan(self, user_message: str) -> EditPlanResponse: + async def _build_edit_plan( + self, user_message: str, conversation_history: list[ConversationMessage] + ) -> EditPlanResponse: return self.edit_plan async def _run_draft_agent(self, request: AgentDraftRequest, edit_plan: EditPlanResponse) -> AgentDraft: @@ -46,7 +48,9 @@ class ClarifyingUserSpecAgent(UserSpecAgent): def __init__(self, runtime: AppRuntime) -> None: super().__init__(runtime) - async def _build_edit_plan(self, user_message: str) -> EditClarificationRequest: + async def _build_edit_plan( + self, user_message: str, conversation_history: list[ConversationMessage] + ) -> EditClarificationRequest: return EditClarificationRequest( question="Which pages should be changed?", reason="The request does not specify the target pages.", diff --git a/frontend/src/prototypes/components/chat/ChatContext.tsx b/frontend/src/prototypes/components/chat/ChatContext.tsx index e6d42e57d7..027bd56959 100644 --- a/frontend/src/prototypes/components/chat/ChatContext.tsx +++ b/frontend/src/prototypes/components/chat/ChatContext.tsx @@ -238,6 +238,8 @@ export function ChatProvider({ children }: { children: ReactNode }) { const { files: activeFiles, fileStubs: activeFileStubs } = useAllFiles(); const { actions: fileActions } = useFileActions(); const abortRef = useRef(null); + const messagesRef = useRef(state.messages); + messagesRef.current = state.messages; // Download a File from the Stirling files endpoint. const downloadFile = useCallback( @@ -323,6 +325,8 @@ export function ChatProvider({ children }: { children: ReactNode }) { const controller = new AbortController(); abortRef.current = controller; + const priorMessages = messagesRef.current; + const userMessage: ChatMessage = { id: crypto.randomUUID(), role: "user", @@ -339,6 +343,10 @@ export function ChatProvider({ children }: { children: ReactNode }) { activeFiles.forEach((file, i) => { formData.append(`fileInputs[${i}].fileInput`, file); }); + priorMessages.forEach((message, i) => { + formData.append(`conversationHistory[${i}].role`, message.role); + formData.append(`conversationHistory[${i}].content`, message.content); + }); const response = await fetch("/api/v1/ai/orchestrate/stream", { method: "POST", From 3b2afe0deb1c50e1e8544ccb7f1ff5d98aa61eb7 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 21 Apr 2026 16:18:25 +0100 Subject: [PATCH 2/2] Change `engine/.env` to be committed and have `.env.local` override (#6150) # Description of Changes We keep adding stuff to `engine/config/.env.example` and have to manually update `.env` because of it, which is really clunky, especially when working on multiple worktrees at once. This PR changes it so that we just have a committed `.env` file and have an `.env.local` override to put the actual private keys into, which should make it a bit easier to manage. > [!warning] > > After this goes in, be very careful for a little while not to accidentally commit any keys that you've got inside your `.env` file! --- .dockerignore | 1 + .gitignore | 1 + .taskfiles/engine.yml | 3 +- engine/{config/.env.example => .env} | 6 ++++ engine/.gitignore | 1 - engine/Dockerfile | 3 +- engine/scripts/setup_env.py | 50 +++++++------------------- engine/src/stirling/config/settings.py | 4 ++- engine/src/stirling/rag/README.md | 9 ++++- 9 files changed, 34 insertions(+), 44 deletions(-) rename engine/{config/.env.example => .env} (81%) diff --git a/.dockerignore b/.dockerignore index 72943ddcc0..ee5c23bed8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -65,6 +65,7 @@ README* .env .env.* !.env.example +!engine/.env # Misc *.swp diff --git a/.gitignore b/.gitignore index 7a632d1a8b..d6dc4826ca 100644 --- a/.gitignore +++ b/.gitignore @@ -165,6 +165,7 @@ __pycache__/ # Virtual environments .env* !.env*.example +!engine/.env .venv* env*/ venv*/ diff --git a/.taskfiles/engine.yml b/.taskfiles/engine.yml index cb357a9dc5..9d830e0808 100644 --- a/.taskfiles/engine.yml +++ b/.taskfiles/engine.yml @@ -20,9 +20,8 @@ tasks: - uv run scripts/setup_env.py sources: - scripts/setup_env.py - - config/.env.example generates: - - .env + - .env.local run: desc: "Run engine server" diff --git a/engine/config/.env.example b/engine/.env similarity index 81% rename from engine/config/.env.example rename to engine/.env index 5431066640..7d1aa13940 100644 --- a/engine/config/.env.example +++ b/engine/.env @@ -1,3 +1,9 @@ +############################################################################### +# Environment variables used within the AI Engine. +# Values can be overridden in the uncommitted sibling `.env.local` file. +# Note: This file is committed to Git, so should not contain any private keys. +############################################################################### + # Configure the model strings passed to pydantic-ai. Provider credentials are handled by # pydantic-ai and should be set using the provider's native environment variables, for example # ANTHROPIC_API_KEY or OPENAI_API_KEY. diff --git a/engine/.gitignore b/engine/.gitignore index 1116f48815..890e5247ba 100644 --- a/engine/.gitignore +++ b/engine/.gitignore @@ -19,7 +19,6 @@ yarn-error.log* .vite/ # Environment -.env .env.local # LaTeX outputs diff --git a/engine/Dockerfile b/engine/Dockerfile index 191d579fa9..5caeb5d6bf 100644 --- a/engine/Dockerfile +++ b/engine/Dockerfile @@ -12,9 +12,8 @@ RUN apt-get update \ WORKDIR /app -COPY pyproject.toml uv.lock Taskfile.yml ./ +COPY pyproject.toml uv.lock Taskfile.yml .env ./ COPY .taskfiles/ ./.taskfiles/ -COPY config/ ./config/ COPY scripts/ ./scripts/ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-dev diff --git a/engine/scripts/setup_env.py b/engine/scripts/setup_env.py index 9626e2c8cf..c459679238 100644 --- a/engine/scripts/setup_env.py +++ b/engine/scripts/setup_env.py @@ -1,48 +1,24 @@ """ -Copies .env from .env.example if missing, and errors if any keys from the example -are absent from the actual .env file. +Ensures `.env.local` exists so developers have a place to put overrides +(API keys, local model choices, etc.) without touching the committed `.env`. Usage: uv run scripts/setup_env.py """ -import os -import shutil -import sys from pathlib import Path -from dotenv import dotenv_values - ROOT = Path(__file__).parent.parent -EXAMPLE_FILE = ROOT / "config" / ".env.example" -ENV_FILE = ROOT / ".env" +ENV_LOCAL_FILE = ROOT / ".env.local" -print("setup-env: see engine/config/.env.example for documentation") +TEMPLATE = """\ +############################################################################### +# Local overrides for `engine/.env` +# Put API keys and machine-specific settings here. Any variable defined here +# takes precedence over the committed `.env` +############################################################################### +""" -if not EXAMPLE_FILE.exists(): - print(f"setup-env: {EXAMPLE_FILE.name} not found, skipping", file=sys.stderr) - sys.exit(0) - -if not ENV_FILE.exists(): - shutil.copy(EXAMPLE_FILE, ENV_FILE) - print("setup-env: created .env from .env.example") - -env_keys = set(dotenv_values(ENV_FILE).keys()) | set(os.environ.keys()) -example_keys = set(dotenv_values(EXAMPLE_FILE).keys()) -missing = sorted(example_keys - env_keys) - -if missing: - sys.exit( - "setup-env: .env is missing keys from .env.example:\n" - + "\n".join(f" {k}" for k in missing) - + "\n Add them manually or delete your local .env to re-copy from config/.env.example." - ) - -extra = sorted(k for k in dotenv_values(ENV_FILE) if k.startswith("STIRLING_") and k not in example_keys) -if extra: - print( - "setup-env: .env contains STIRLING_ keys not in config/.env.example:\n" - + "\n".join(f" {k}" for k in extra) - + "\n Add them to config/.env.example if they are intentional.", - file=sys.stderr, - ) +if not ENV_LOCAL_FILE.exists(): + ENV_LOCAL_FILE.write_text(TEMPLATE) + print("setup-env: created empty .env.local for local overrides") diff --git a/engine/src/stirling/config/settings.py b/engine/src/stirling/config/settings.py index 2452b8ec8c..d4e6212283 100644 --- a/engine/src/stirling/config/settings.py +++ b/engine/src/stirling/config/settings.py @@ -12,6 +12,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict ENGINE_ROOT = Path(__file__).resolve().parents[3] ENV_FILE = ENGINE_ROOT / ".env" +ENV_LOCAL_FILE = ENGINE_ROOT / ".env.local" class RagBackend(StrEnum): @@ -20,7 +21,7 @@ class RagBackend(StrEnum): class AppSettings(BaseSettings): - model_config = SettingsConfigDict(env_file=ENV_FILE, extra="ignore", populate_by_name=True) + model_config = SettingsConfigDict(env_file=(ENV_FILE, ENV_LOCAL_FILE), extra="ignore", populate_by_name=True) smart_model_name: str = Field(validation_alias="STIRLING_SMART_MODEL") fast_model_name: str = Field(validation_alias="STIRLING_FAST_MODEL") @@ -74,6 +75,7 @@ def _configure_logging(level_name: str, log_file: str) -> None: @lru_cache(maxsize=1) def load_settings() -> AppSettings: load_dotenv(ENV_FILE) + load_dotenv(ENV_LOCAL_FILE, override=True) settings = AppSettings.model_validate({}) _configure_logging(settings.log_level, settings.log_file) return settings diff --git a/engine/src/stirling/rag/README.md b/engine/src/stirling/rag/README.md index a4bdb6c452..309a3a7c9b 100644 --- a/engine/src/stirling/rag/README.md +++ b/engine/src/stirling/rag/README.md @@ -37,7 +37,9 @@ multi = RagCapability(runtime.rag_service, collections=["company-docs", "product everything = RagCapability(runtime.rag_service) ``` -## Config (.env) +## Config + +Non-secret defaults live in the committed `engine/.env`: ``` STIRLING_RAG_BACKEND=sqlite # or "pgvector" @@ -47,6 +49,11 @@ STIRLING_RAG_PGVECTOR_DSN= # used when backend=pgvector STIRLING_RAG_CHUNK_SIZE=512 STIRLING_RAG_CHUNK_OVERLAP=64 STIRLING_RAG_TOP_K=5 +``` + +Provider credentials (and any local overrides) go in the uncommitted `engine/.env.local`: + +``` VOYAGE_API_KEY=your-key ```