chore: merge upstream main into session activity branch
Resolve changelog and session cache conflicts while preserving last_message_at behavior.
This commit is contained in:
commit
aca4f69c05
|
|
@ -8,6 +8,48 @@
|
|||
#
|
||||
# The workflow gates only understand individual @usernames (no @org/team
|
||||
# entries).
|
||||
#
|
||||
# Order matters: GitHub applies the LAST matching pattern, so narrower rules
|
||||
# go further down. Paths not listed here have no automatic reviewer.
|
||||
|
||||
# Telemetry, tracing, metrics.
|
||||
/src/telemetry/ @akattelu @Rajat-Ahuja1997
|
||||
|
||||
# Data model, connections, configuration, LLM transport.
|
||||
/src/db.py @akattelu @eisene
|
||||
/src/models.py @akattelu @eisene
|
||||
/src/config.py @akattelu @eisene
|
||||
/src/cache/ @akattelu @eisene
|
||||
/src/crud/ @akattelu @eisene
|
||||
/migrations/ @akattelu @eisene
|
||||
/src/llm/ @akattelu @eisene
|
||||
|
||||
# Client-facing surfaces and API shape.
|
||||
/sdks/ @ajspig @akattelu
|
||||
/mcp/ @ajspig @akattelu
|
||||
/honcho-cli/ @ajspig @akattelu
|
||||
/src/routers/ @ajspig @akattelu
|
||||
/src/schemas/ @ajspig @akattelu
|
||||
|
||||
# The reasoning agents, their prompts, and shared agent tooling.
|
||||
/src/deriver/ @eisene @akattelu
|
||||
/src/dreamer/ @eisene @akattelu
|
||||
/src/dialectic/ @eisene @akattelu
|
||||
/src/utils/ @eisene @akattelu
|
||||
|
||||
# Deployment, and swappable storage and inference backends.
|
||||
# /src/llm/backends/ must stay below /src/llm/ above — last match wins.
|
||||
/docker/ @eisene @Rajat-Ahuja1997
|
||||
/Dockerfile @eisene @Rajat-Ahuja1997
|
||||
/docker-compose.yml.example @eisene @Rajat-Ahuja1997
|
||||
/src/vector_store/ @eisene @Rajat-Ahuja1997
|
||||
/src/llm/backends/ @eisene @Rajat-Ahuja1997
|
||||
|
||||
# Documentation and contributor-facing policy.
|
||||
/docs/ @ajspig @akattelu
|
||||
/README.md @ajspig @akattelu
|
||||
/CONTRIBUTING.md @akattelu @ajspig
|
||||
/SECURITY.md @Rajat-Ahuja1997 @ajspig
|
||||
|
||||
# Reviewers auto-requested on changes under .github/ (workflows, this file,
|
||||
# templates).
|
||||
|
|
@ -16,4 +58,4 @@
|
|||
# CI-trigger allowlist only: this path matches no real file, so these people
|
||||
# are never auto-requested for review, but the workflow gates still pick
|
||||
# them up.
|
||||
/ci-trigger-allowlist @3un01a @adavyas @ajspig @courtlandleer @erosika @lowyelling @matthewlanders @vintrocode
|
||||
/ci-trigger-allowlist @ajspig @courtlandleer @erosika @lowyelling @vintrocode
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
---
|
||||
name: "🐞 Bug Report"
|
||||
about: "Report an issue to help the project improve."
|
||||
title: "[Bug] "
|
||||
labels: "bug"
|
||||
assignees: ""
|
||||
|
||||
---
|
||||
|
||||
# **🐞 Bug Report**
|
||||
|
||||
## **Describe the bug**
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
*
|
||||
|
||||
---
|
||||
|
||||
### **Is this a regression?**
|
||||
<!-- Did this behaviour used to work in the previous version? -->
|
||||
<!-- Yes, the last version in which this bug was not present was: ... -->
|
||||
|
||||
---
|
||||
|
||||
### **To Reproduce**
|
||||
|
||||
<!-- Steps to reproduce the error:
|
||||
(e.g.:)
|
||||
1. Use x argument / navigate to
|
||||
2. Fill this information
|
||||
3. Go to...
|
||||
4. See error -->
|
||||
|
||||
<!-- Write the steps here (add or remove as many steps as needed)-->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
4.
|
||||
|
||||
---
|
||||
|
||||
### **Expected behaviour**
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
*
|
||||
|
||||
---
|
||||
|
||||
### **Media prove**
|
||||
<!-- If applicable, add screenshots or videos to help explain your problem. -->
|
||||
|
||||
---
|
||||
|
||||
### **Your environment**
|
||||
|
||||
<!-- use all the applicable bulleted list elements for this specific issue,
|
||||
and remove all the bulleted list elements that are not relevant for this issue. -->
|
||||
|
||||
* OS: <!--[e.g. Ubuntu 5.4.0-26-generic x86_64 / Windows 1904 ...]-->
|
||||
* Browser name and version:
|
||||
* Honcho Server Version: <!-- e.g. v0.0.8 -->
|
||||
* Honcho Client Version: <!-- e.g. Python v0.0.8 -->
|
||||
|
||||
---
|
||||
|
||||
### **Additional context**
|
||||
<!-- Add any other context or additional information about the problem here.-->
|
||||
|
||||
*
|
||||
|
||||
<!--📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛
|
||||
|
||||
To expedite issue processing, please search open and closed issues before submitting a new one.
|
||||
|
||||
📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛-->
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
name: Bug report
|
||||
description: Something is broken or incorrect in Honcho (API, deriver, SDK, managed offering, etc.).
|
||||
title: "[Bug] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing a bug. Please search [existing issues](https://github.com/plastic-labs/honcho/issues) first.
|
||||
|
||||
**Security vulnerability?** Do not use this form — report privately via [SECURITY.md](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md).
|
||||
|
||||
**Memory / recall quality** (wrong or noisy conclusions, weak dialectic answers) with no crash? Prefer the **Memory / recall quality** template.
|
||||
|
||||
- type: dropdown
|
||||
id: deploy_mode
|
||||
attributes:
|
||||
label: Deploy mode
|
||||
description: Where are you running Honcho?
|
||||
options:
|
||||
- Managed (api.honcho.dev / app.honcho.dev)
|
||||
- Self-hosted
|
||||
- Unsure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Honcho version
|
||||
description: Server image tag or release, and SDK version if you use one. Write "managed" if you are not self-hosting.
|
||||
placeholder: e.g. server v2.4.1, honcho-ai 2.1.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: Clear and concise description of what is wrong.
|
||||
placeholder: When I…, Honcho…
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Minimal steps or a short script/API sequence. Redact secrets, JWTs, and production user content.
|
||||
placeholder: |
|
||||
1. Create a session with …
|
||||
2. POST /v3/... with body …
|
||||
3. Observe …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs and evidence
|
||||
description: Relevant API or deriver logs or stack traces. Redact secrets and user content.
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Config knobs, deployment notes, screenshots, related issues/PRs.
|
||||
validations:
|
||||
required: false
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
---
|
||||
name: "💉 Failing Test"
|
||||
about: "Report failing tests or CI jobs."
|
||||
title: "[Test] "
|
||||
labels: "Type: Test"
|
||||
assignees: ""
|
||||
|
||||
---
|
||||
|
||||
# **💉 Failing Test**
|
||||
|
||||
## **Which jobs/test(s) are failing**
|
||||
<!-- The CI jobs or tests that are failing -->
|
||||
|
||||
*
|
||||
|
||||
---
|
||||
|
||||
## **Reason for failure/description**
|
||||
<!-- Try to describe why the test is failing or what we are missing to make it pass. -->
|
||||
|
||||
---
|
||||
|
||||
### **Media prove**
|
||||
<!-- If applicable, add screenshots or videos to help explain your problem. -->
|
||||
|
||||
---
|
||||
|
||||
### **Additional context**
|
||||
<!-- Add any other context or additional information about the problem here. -->
|
||||
|
||||
*
|
||||
|
||||
<!--📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛
|
||||
|
||||
To expedite issue processing, please search open and closed issues before submitting a new one.
|
||||
|
||||
📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛-->
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
name: Memory / recall quality
|
||||
description: Conclusions, representations, or dialectic answers are wrong, noisy, missing, or low-quality — not a hard crash.
|
||||
title: "[Quality] "
|
||||
labels: ["quality"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this when Honcho runs without erroring, but **memory formation or recall quality** is off (bad conclusions, missed facts, weak chat answers, polluted representations, etc.).
|
||||
|
||||
For crashes, 5xxs, auth failures, or incorrect API mechanics, use the **Bug report** template instead.
|
||||
|
||||
**Do not paste production user content, full peer representations, or secrets.** Redact or invent a minimal synthetic example.
|
||||
|
||||
- type: dropdown
|
||||
id: deploy_mode
|
||||
attributes:
|
||||
label: Deploy mode
|
||||
options:
|
||||
- Managed (api.honcho.dev / app.honcho.dev)
|
||||
- Self-hosted
|
||||
- Unsure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Honcho version
|
||||
description: Server image tag or release, and SDK version if you use one. Write "managed" if you are not self-hosting.
|
||||
placeholder: e.g. server v2.4.1, honcho-ai 2.1.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What is wrong with the quality?
|
||||
description: Describe the failure mode (noise, omission, contradiction, staleness, over/under-generalization, etc.).
|
||||
placeholder: After ingesting messages about X, Honcho concludes Y / chat answers Z…
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Minimal scenario
|
||||
description: >
|
||||
Smallest synthetic message sequence or setup that triggers the issue.
|
||||
Prefer invented names/facts over real user data. Include observer/observed
|
||||
peer setup if relevant (self vs cross-peer).
|
||||
placeholder: |
|
||||
1. Peers: alice (user), bot (agent); session S
|
||||
2. Messages ingested: …
|
||||
3. Query / conclusion listing shows: …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: config
|
||||
attributes:
|
||||
label: Relevant config
|
||||
description: >
|
||||
Custom instructions, provider/model, deriver/dream settings, or workspace/peer
|
||||
config that affects reasoning. Redact secrets.
|
||||
placeholder: |
|
||||
Provider/model: …
|
||||
Custom instructions: (summary or redacted)
|
||||
Other: …
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: evidence
|
||||
attributes:
|
||||
label: Evidence
|
||||
description: Redacted conclusion text, chat excerpts, or counts that show the failure. No production PII.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Frequency, scale (message/conclusion counts), related issues, workarounds.
|
||||
validations:
|
||||
required: false
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
name: Feature request
|
||||
description: Propose a new capability or an improvement to an existing one.
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Tell us what problem you are trying to solve. Concrete use cases beat abstract wishlists.
|
||||
|
||||
Questions about how to use Honcho belong on [Discord](https://discord.gg/honcho), not here.
|
||||
|
||||
- type: dropdown
|
||||
id: request_type
|
||||
attributes:
|
||||
label: Request type
|
||||
options:
|
||||
- New capability
|
||||
- Improve an existing capability
|
||||
- API / SDK surface
|
||||
- Managed offering
|
||||
- Docs / DX
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What is hard or impossible today? Who hits this?
|
||||
placeholder: I'm always frustrated when… / My integration needs…
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: What you would like Honcho to support. Sketches and API shapes welcome.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Workarounds, other APIs, or designs you already tried or ruled out.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Links, prior art, screenshots, related issues/PRs.
|
||||
validations:
|
||||
required: false
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
---
|
||||
name: "🚀🆕 Feature Request"
|
||||
about: "Suggest an idea or possible new feature for this project."
|
||||
title: ""
|
||||
labels: 'feature'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
# **🚀 Feature Request**
|
||||
|
||||
## **Is your feature request related to a problem? Please describe.**
|
||||
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
|
||||
|
||||
*
|
||||
|
||||
---
|
||||
|
||||
## **Describe the solution you'd like**
|
||||
<!-- A clear and concise description of what you want to happen. -->
|
||||
|
||||
*
|
||||
|
||||
---
|
||||
|
||||
## **Describe alternatives you've considered**
|
||||
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
|
||||
|
||||
*
|
||||
|
||||
---
|
||||
|
||||
### **Additional context**
|
||||
<!-- Add any other context or additional information about the problem here.-->
|
||||
|
||||
*
|
||||
|
||||
<!--📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛
|
||||
|
||||
To expedite issue processing, please search open and closed issues before submitting a new one.
|
||||
|
||||
📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛-->
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
name: Integration request
|
||||
description: Add Honcho to an app store, agent framework, plugin marketplace, or other third-party surface — or improve an existing integration.
|
||||
title: "[Integration] "
|
||||
labels: ["integration"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this when you want Honcho available in (or better supported by) an external product surface — app stores, agent frameworks, plugin marketplaces, IDE extensions, MCP clients, etc.
|
||||
|
||||
For core API/SDK product features that are not about a third-party surface, use the **Feature request** template instead.
|
||||
|
||||
- type: dropdown
|
||||
id: request_kind
|
||||
attributes:
|
||||
label: What kind of request is this?
|
||||
options:
|
||||
- New integration / listing (Honcho is not there yet)
|
||||
- Improve an existing integration
|
||||
- Official plugin / extension
|
||||
- Marketplace or app-store listing
|
||||
- Docs / guide for integrating with a specific tool
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: target
|
||||
attributes:
|
||||
label: Target product or platform
|
||||
description: Name of the app, framework, marketplace, or tool.
|
||||
placeholder: e.g. Claude Code, Cursor, CrewAI, OpenClaw, VS Code Marketplace…
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: target_url
|
||||
attributes:
|
||||
label: Link (if any)
|
||||
description: Docs, marketplace page, repo, or product URL.
|
||||
placeholder: https://…
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why does this matter?
|
||||
description: Who would use it, and what does the integration unlock?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: shape
|
||||
attributes:
|
||||
label: What should the integration look like?
|
||||
description: >
|
||||
e.g. one-click install, MCP server listing, native memory backend,
|
||||
SDK recipe, plugin with slash commands, env-var setup, etc.
|
||||
Link to prior art or a sketch if you have one.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Related issues, community demand, constraints, offers to help build it.
|
||||
validations:
|
||||
required: false
|
||||
|
|
@ -8,6 +8,10 @@ assignees: ""
|
|||
---
|
||||
# **📚 Documentation Issue Report**
|
||||
|
||||
**Security vulnerability?** Do not use this form — report privately via [SECURITY.md](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md).
|
||||
|
||||
GitHub issues are public. Redact secrets, JWTs, and production user content.
|
||||
|
||||
## **Describe the bug**
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
|
|
@ -33,8 +37,8 @@ assignees: ""
|
|||
|
||||
---
|
||||
|
||||
### **Media prove**
|
||||
<!-- If applicable, add screenshots or videos to help explain your problem. -->
|
||||
### **Screenshots and videos**
|
||||
<!-- If applicable, add screenshots or videos to help explain your problem. Redact secrets and production content. -->
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -46,7 +50,7 @@ assignees: ""
|
|||
---
|
||||
|
||||
### **Additional context**
|
||||
<!-- Add any other context or additional information about the problem here.-->
|
||||
<!-- Add any other context about the problem. Redact secrets and production user content. -->
|
||||
|
||||
*
|
||||
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
---
|
||||
name: "🚀➕ Enhancement Request"
|
||||
about: "Suggest an enhancement for this project. Improve an existing feature"
|
||||
title: ""
|
||||
labels: "Type: Enhancement"
|
||||
assignees: ""
|
||||
|
||||
---
|
||||
|
||||
# **🚀 Enhancement Request**
|
||||
|
||||
## **Is your enhancement request related to a problem? Please describe.**
|
||||
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
|
||||
|
||||
*
|
||||
|
||||
---
|
||||
|
||||
## **Describe the solution you'd like**
|
||||
<!-- A clear and concise description of what you want to happen. -->
|
||||
|
||||
*
|
||||
|
||||
---
|
||||
|
||||
## **Describe alternatives you've considered**
|
||||
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
|
||||
|
||||
*
|
||||
|
||||
---
|
||||
|
||||
### **Additional context**
|
||||
<!-- Add any other context or additional information about the problem here.-->
|
||||
|
||||
*
|
||||
|
||||
<!--📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛
|
||||
|
||||
To expedite issue processing, please search open and closed issues before submitting a new one.
|
||||
|
||||
📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛-->
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
---
|
||||
name: "⚠️ Security Report"
|
||||
about: "Report an issue to help the project improve."
|
||||
title: ""
|
||||
labels: "security"
|
||||
assignees: ""
|
||||
|
||||
---
|
||||
|
||||
<!--📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛
|
||||
|
||||
READ CAREFULLY IF YOUR ISSUE REPORT CONTAINS SENSIBLE OR PRIVATE DATA:
|
||||
(data that might be leaked or subtracted from our servers due to this
|
||||
security issue).
|
||||
|
||||
If this security report (or the guide on how to "identify the security bug") includes
|
||||
certain personal information or involves personal identifiable data, or you believe
|
||||
that the data that you might leak by exposing the way on how to attack the project
|
||||
could be considered as a data leak or could violate the privacy of any kind of
|
||||
data or sensible data, please do not post it here and directly email the developer:
|
||||
(hello@plasticlabs.ai). You should post the issue with the least amount of
|
||||
sensible or private data as possible to help us manage the security issue, and
|
||||
with the extra data sent from your email to the developer (if any), we will deeply
|
||||
analyze and try to fix it as fast as possible.
|
||||
|
||||
If you are in doubt about the data that you might post here (screenshots or media
|
||||
also, count as data), please directly email us.
|
||||
|
||||
The data that must NOT be posted here:
|
||||
|
||||
* Legal and/or full names
|
||||
* Names or usernames combined with other identifiers like phone numbers or email addresses
|
||||
* Health or financial information (including insurance information, social security numbers, etc.)
|
||||
* Information about political or religious affiliations
|
||||
* Information about race, ethnicity, sexual orientation, gender, or other identifying information that could be used for discriminatory purposes
|
||||
|
||||
📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛-->
|
||||
|
||||
# **⚠️ Security Report**
|
||||
|
||||
## **Describe the security issue**
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
*
|
||||
|
||||
---
|
||||
|
||||
### **To Reproduce**
|
||||
|
||||
<!-- Steps to reproduce the error:
|
||||
(e.g.:)
|
||||
1. Use x argument / navigate to
|
||||
2. Fill this information
|
||||
3. Go to...
|
||||
4. See error -->
|
||||
|
||||
<!-- Write the steps here (add or remove as many steps as needed)-->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
4.
|
||||
|
||||
---
|
||||
|
||||
### **Expected behaviour**
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
*
|
||||
|
||||
---
|
||||
|
||||
### **Media prove**
|
||||
<!-- If applicable, add screenshots or videos to help explain your problem. -->
|
||||
|
||||
---
|
||||
|
||||
### **Your environment**
|
||||
|
||||
<!-- use all the applicable bulleted list elements for this specific issue,
|
||||
and remove all the bulleted list elements that are not relevant for this issue. -->
|
||||
|
||||
* OS: <!--[e.g. Ubuntu 5.4.0-26-generic x86_64 / Windows 1904 ...]-->
|
||||
* Browser name and version:
|
||||
* Honcho Server Version: <!--[e.g. v0.0.1]-->
|
||||
* Honcho Client Version: <!--[e.g. Python v0.0.1]-->
|
||||
|
||||
---
|
||||
|
||||
### **Additional context**
|
||||
<!-- Add any other context or additional information about the problem here.-->
|
||||
|
||||
*
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
name: "❓ Question or Support Request"
|
||||
about: "Questions and requests for support."
|
||||
title: ""
|
||||
labels: "question"
|
||||
assignees: ""
|
||||
|
||||
---
|
||||
|
||||
# **❓ Question or Support Request**
|
||||
|
||||
## **Describe your question or ask for support.**
|
||||
<!-- A clear and concise description of what your doubt is. -->
|
||||
|
||||
*
|
||||
|
||||
<!--📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛
|
||||
|
||||
Before posting any questions or asking for support, first read the project's README.md file and
|
||||
(if there is any) the WIKI pages or any other additional documentation that might be listed
|
||||
in the project's README.md file.
|
||||
|
||||
To expedite issue processing, please search open and closed issues before submitting a new one.
|
||||
|
||||
📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛-->
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Report a security vulnerability
|
||||
url: https://github.com/plastic-labs/honcho/security/advisories/new
|
||||
about: Private vulnerability reporting only — do not file public security issues.
|
||||
- name: Question or support
|
||||
url: https://discord.gg/honcho
|
||||
about: Ask the community and maintainers on Discord.
|
||||
- name: Documentation
|
||||
url: https://honcho.dev/docs
|
||||
about: Guides, API reference, and self-hosting docs.
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Issue gate — shared logic for `.github/workflows/issue-gate.yml` (immediate
|
||||
* feedback on pull request events) and `.github/workflows/pr-sweeper.yml`
|
||||
* (deferred re-check, close, and stale-draft cleanup).
|
||||
*
|
||||
* Both workflows `require` this file through actions/github-script, so it must
|
||||
* stay dependency-free: neither job runs an install step.
|
||||
*
|
||||
* See CONTRIBUTING.md for the policy this enforces.
|
||||
*/
|
||||
|
||||
const REQUIRED_LABEL = 'maintainer-approved';
|
||||
const GATE_LABEL = 'needs-approved-issue';
|
||||
const EXEMPT_LABEL = 'gate-exempt';
|
||||
const MARKER = '<!-- issue-gate -->';
|
||||
const DISCORD = 'http://discord.gg/honcho';
|
||||
|
||||
// Hours a labelled pull request has before the sweeper closes it. Measured from
|
||||
// the notice comment, so the clock starts when the author was actually told —
|
||||
// not when the pull request was opened.
|
||||
const GRACE_HOURS = 72;
|
||||
|
||||
// Days without activity before a draft from outside the org is closed.
|
||||
const DRAFT_STALE_DAYS = 30;
|
||||
|
||||
const hasLabel = (pr, name) => (pr.labels || []).some((l) => l.name === name);
|
||||
|
||||
const isBot = (account) => Boolean(account) && account.type === 'Bot';
|
||||
|
||||
/**
|
||||
* Why this pull request is exempt from the gate, or null if it is not.
|
||||
*
|
||||
* Single source of truth: every caller that acts on a pull request runs this.
|
||||
*/
|
||||
const exemptReason = async ({ github, owner, repo, pr }) => {
|
||||
if (isBot(pr.user)) return 'author is a bot';
|
||||
if (hasLabel(pr, EXEMPT_LABEL)) return `carries the ${EXEMPT_LABEL} label`;
|
||||
|
||||
const username = pr.user && pr.user.login;
|
||||
if (!username) return null;
|
||||
|
||||
const permission = await repoPermission({ github, owner, repo, username });
|
||||
if (WRITE_PERMISSIONS.includes(permission)) {
|
||||
return `author has ${permission} permission`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Repo roles that skip the gate. `read` / `triage` do not.
|
||||
const WRITE_PERMISSIONS = ['admin', 'maintain', 'write'];
|
||||
|
||||
/** Highest repo permission for `username`, or null if they are not a collaborator. */
|
||||
async function repoPermission({ github, owner, repo, username }) {
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner, repo, username,
|
||||
});
|
||||
return data.permission;
|
||||
} catch (err) {
|
||||
if (err && err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const CLOSING_ISSUES = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
closingIssuesReferences(first: 20) {
|
||||
nodes {
|
||||
number
|
||||
state
|
||||
labels(first: 50) { nodes { name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Decide whether a pull request clears the gate.
|
||||
*
|
||||
* Reads GitHub's own resolved issue links rather than parsing the body, so both
|
||||
* `Fixes #123` and the sidebar "Development" link count. A bare `#123` mention
|
||||
* deliberately does not — that is a reference, not a claim to close.
|
||||
*
|
||||
* @returns {Promise<{passed: boolean, skipped?: string, issue?: number, reason?: string}>}
|
||||
*/
|
||||
async function checkGate({ github, owner, repo, pr }) {
|
||||
if (pr.state !== 'open') return { passed: true, skipped: 'pull request is not open' };
|
||||
if (pr.draft) return { passed: true, skipped: 'pull request is a draft' };
|
||||
const exempt = await exemptReason({ github, owner, repo, pr });
|
||||
if (exempt) return { passed: true, skipped: exempt };
|
||||
|
||||
const data = await github.graphql(CLOSING_ISSUES, { owner, repo, number: pr.number });
|
||||
const issues = data.repository.pullRequest.closingIssuesReferences.nodes;
|
||||
|
||||
if (issues.length === 0) {
|
||||
return { passed: false, reason: 'This pull request is not linked to an issue.' };
|
||||
}
|
||||
|
||||
const approved = issues.find(
|
||||
(i) => i.state === 'OPEN' && i.labels.nodes.some((l) => l.name === REQUIRED_LABEL),
|
||||
);
|
||||
if (approved) return { passed: true, issue: approved.number };
|
||||
|
||||
const detail = issues
|
||||
.map((i) => `#${i.number} (${i.state === 'CLOSED' ? 'closed' : 'not approved'})`)
|
||||
.join(', ');
|
||||
return {
|
||||
passed: false,
|
||||
reason:
|
||||
`The linked ${issues.length === 1 ? 'issue is' : 'issues are'} not open with the ` +
|
||||
`\`${REQUIRED_LABEL}\` label: ${detail}.`,
|
||||
};
|
||||
}
|
||||
|
||||
function noticeBody({ owner, repo, reason }) {
|
||||
return [
|
||||
MARKER,
|
||||
'Thanks for the contribution. This pull request does not clear our issue gate yet.',
|
||||
'',
|
||||
`**${reason}**`,
|
||||
'',
|
||||
`Every pull request to Honcho needs to be linked to an open issue carrying the \`${REQUIRED_LABEL}\` label. We do this so the review queue only holds work we have already agreed should be built — it means nobody spends time on a change we cannot merge.`,
|
||||
'',
|
||||
'To get this moving:',
|
||||
'',
|
||||
`1. Find or open an issue describing the change. [Approved issues are here](https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3A${REQUIRED_LABEL}).`,
|
||||
`2. Make the case for it in [Discord](${DISCORD}) — maintainers are most active there, and it is by far the fastest route to a decision.`,
|
||||
`3. Once the issue has the label, link it: put \`Fixes #<number>\` in this pull request's description, or use **Development** in the sidebar.`,
|
||||
'',
|
||||
`**This will close automatically in ${GRACE_HOURS} hours if it is still unlinked.** Nothing is lost if that happens — link the issue, reopen, and it goes into the review queue.`,
|
||||
'',
|
||||
`See [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md) for the full process. If you think this is wrong, say so here and a maintainer will take a look.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every gate notice this bot posted on a pull request, oldest first.
|
||||
*
|
||||
* Authorship is part of the test, not decoration. MARKER is an invisible HTML
|
||||
* comment, so anyone who can comment on a public repository can paste it. If
|
||||
* user comments counted, a third party could post one on someone else's pull
|
||||
* request: `runGate` posts a notice only when none exists, so the author would
|
||||
* never be told, and `runSweep` would then measure the grace window from the
|
||||
* stranger's timestamp and close them unwarned.
|
||||
*/
|
||||
async function findNotices({ github, owner, repo, number }) {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: number, per_page: 100,
|
||||
});
|
||||
return comments.filter((c) => isBot(c.user) && (c.body || '').includes(MARKER));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the gate label and delete the notice.
|
||||
*
|
||||
* Deleting matters: `runGate` posts a notice only when none exists, and the
|
||||
* sweeper measures grace from the notice timestamp. A notice left behind after
|
||||
* the gate clears would make a later re-block look weeks old and be closed with
|
||||
* no warning.
|
||||
*/
|
||||
async function clearGate({ github, owner, repo, pr }) {
|
||||
if (hasLabel(pr, GATE_LABEL)) {
|
||||
await github.rest.issues
|
||||
.removeLabel({ owner, repo, issue_number: pr.number, name: GATE_LABEL })
|
||||
.catch(() => {});
|
||||
}
|
||||
for (const notice of await findNotices({ github, owner, repo, number: pr.number })) {
|
||||
await github.rest.issues
|
||||
.deleteComment({ owner, repo, comment_id: notice.id })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for `.github/workflows/issue-gate.yml`.
|
||||
* Labels and explains. Never closes — that is the sweeper's job.
|
||||
*/
|
||||
async function runGate({ github, core, context }) {
|
||||
const pr = context.payload.pull_request;
|
||||
const { owner, repo } = context.repo;
|
||||
const result = await checkGate({ github, owner, repo, pr });
|
||||
|
||||
if (result.passed) {
|
||||
core.info(
|
||||
result.skipped ? `Skipping gate: ${result.skipped}` : `Gate passed via #${result.issue}`,
|
||||
);
|
||||
await clearGate({ github, owner, repo, pr });
|
||||
return;
|
||||
}
|
||||
|
||||
core.warning(`Gate failed: ${result.reason}`);
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: pr.number, labels: [GATE_LABEL],
|
||||
});
|
||||
|
||||
const notices = await findNotices({ github, owner, repo, number: pr.number });
|
||||
if (notices.length > 0) return;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: pr.number,
|
||||
body: noticeBody({ owner, repo, reason: result.reason }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Entry point for `.github/workflows/pr-sweeper.yml`. */
|
||||
async function runSweep({ github, core, context, dryRun }) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
const act = async (what, fn) => {
|
||||
core.info(dryRun ? `[dry run] ${what}` : what);
|
||||
if (!dryRun) await fn();
|
||||
};
|
||||
|
||||
const close = (pr, body) => async () => {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
|
||||
await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' });
|
||||
};
|
||||
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
core.info(`${prs.length} open pull requests${dryRun ? ' (dry run)' : ''}`);
|
||||
|
||||
// Re-check everything wearing the gate label. Never close blind: a pull request
|
||||
// linked through the sidebar fires no webhook, so the gate workflow cannot have
|
||||
// noticed it — this pass is the only thing that will.
|
||||
for (const pr of prs.filter((p) => hasLabel(p, GATE_LABEL))) {
|
||||
const result = await checkGate({ github, owner, repo, pr });
|
||||
|
||||
if (result.passed) {
|
||||
const why = result.skipped || `via #${result.issue}`;
|
||||
await act(`#${pr.number}: gate now clear (${why})`, async () => {
|
||||
await clearGate({ github, owner, repo, pr });
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: pr.number,
|
||||
body: 'The issue link is in place — this pull request has cleared the gate and is waiting on review.',
|
||||
});
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const [notice] = await findNotices({ github, owner, repo, number: pr.number });
|
||||
if (!notice) {
|
||||
core.info(`#${pr.number}: labelled but never notified — leaving it for the gate workflow`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hours = (Date.now() - Date.parse(notice.created_at)) / 3_600_000;
|
||||
if (hours < GRACE_HOURS) {
|
||||
core.info(`#${pr.number}: ${Math.round(GRACE_HOURS - hours)}h of grace left`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await act(`#${pr.number}: closing — notified ${Math.round(hours)}h ago, still failing`, close(pr,
|
||||
`Closing this: ${GRACE_HOURS} hours have passed and the gate is still not clear. This is not a judgement on the code. Link an approved issue and reopen — it goes straight into the review queue.`,
|
||||
));
|
||||
}
|
||||
|
||||
// Stale drafts. The gate skips drafts entirely, so they never carry the label;
|
||||
// this pass keys off inactivity and applies the shared exemptions itself.
|
||||
for (const pr of prs.filter((p) => p.draft)) {
|
||||
const exempt = await exemptReason({ github, owner, repo, pr });
|
||||
if (exempt) {
|
||||
core.info(`#${pr.number}: leaving stale draft alone — ${exempt}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const days = (Date.now() - Date.parse(pr.updated_at)) / 86_400_000;
|
||||
if (days < DRAFT_STALE_DAYS) continue;
|
||||
|
||||
await act(`#${pr.number}: closing stale draft — ${Math.round(days)}d without activity`, close(pr,
|
||||
`Closing this draft after ${DRAFT_STALE_DAYS} days without activity, to keep the pull request list readable. Reopen whenever you pick it back up — nothing here is lost.`,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkGate, runGate, runSweep, noticeBody, findNotices, exemptReason,
|
||||
REQUIRED_LABEL, GATE_LABEL, EXEMPT_LABEL, MARKER, GRACE_HOURS, DRAFT_STALE_DAYS,
|
||||
};
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
'use strict';
|
||||
|
||||
// Self-check for the gate decision logic. No framework, no install:
|
||||
// node .github/scripts/issue-gate.test.js
|
||||
// Covers checkGate() only — the side-effecting halves (runGate/runSweep) are
|
||||
// exercised against the real API via `pr-sweeper.yml`'s dry_run dispatch.
|
||||
|
||||
const assert = require('node:assert');
|
||||
const {
|
||||
checkGate, findNotices, runSweep, REQUIRED_LABEL, EXEMPT_LABEL, MARKER,
|
||||
} = require('./issue-gate.js');
|
||||
|
||||
const pull = (over = {}) => ({
|
||||
number: 1, state: 'open', draft: false,
|
||||
user: { type: 'User', login: 'alice' }, labels: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const notCollaborator = () => {
|
||||
const err = new Error('Not Found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
};
|
||||
|
||||
// `linked` is the list of issues GitHub resolves as closing references.
|
||||
const stub = (linked, permission) => ({
|
||||
graphql: async () => ({
|
||||
repository: { pullRequest: { closingIssuesReferences: {
|
||||
nodes: linked.map((i) => ({
|
||||
number: i.number, state: i.state || 'OPEN',
|
||||
labels: { nodes: (i.labels || []).map((name) => ({ name })) },
|
||||
})),
|
||||
} } },
|
||||
}),
|
||||
rest: {
|
||||
repos: {
|
||||
getCollaboratorPermissionLevel: async () => {
|
||||
if (!permission) return notCollaborator();
|
||||
return { data: { permission } };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const run = (linked, over, permission) =>
|
||||
checkGate({ github: stub(linked, permission), owner: 'o', repo: 'r', pr: pull(over) });
|
||||
|
||||
const cases = [
|
||||
['no linked issue fails', () => run([]), (r) => r.passed === false],
|
||||
['linked but unapproved fails', () => run([{ number: 7 }]), (r) => r.passed === false],
|
||||
['linked and approved passes',
|
||||
() => run([{ number: 7, labels: [REQUIRED_LABEL] }]),
|
||||
(r) => r.passed === true && r.issue === 7],
|
||||
['approved but closed fails',
|
||||
() => run([{ number: 7, state: 'CLOSED', labels: [REQUIRED_LABEL] }]),
|
||||
(r) => r.passed === false],
|
||||
['picks the approved one out of several',
|
||||
() => run([{ number: 7 }, { number: 8, labels: [REQUIRED_LABEL] }]),
|
||||
(r) => r.passed === true && r.issue === 8],
|
||||
|
||||
// Exemptions.
|
||||
['write permission skips', () => run([], {}, 'write'), (r) => r.passed === true],
|
||||
['maintain permission skips', () => run([], {}, 'maintain'), (r) => r.passed === true],
|
||||
['bot skips', () => run([], { user: { type: 'Bot' } }), (r) => r.passed === true],
|
||||
['draft skips', () => run([], { draft: true }), (r) => r.passed === true],
|
||||
[`${EXEMPT_LABEL} skips`, () => run([], { labels: [{ name: EXEMPT_LABEL }] }), (r) => r.passed === true],
|
||||
|
||||
['triage permission is still gated', () => run([], {}, 'triage'), (r) => r.passed === false],
|
||||
['MEMBER association without write is still gated',
|
||||
() => run([], { author_association: 'MEMBER' }),
|
||||
(r) => r.passed === false],
|
||||
['CONTRIBUTOR with write skips',
|
||||
() => run([], { author_association: 'CONTRIBUTOR' }, 'write'),
|
||||
(r) => r.passed === true],
|
||||
];
|
||||
|
||||
// --- findNotices: only the bot's own notices count -------------------------
|
||||
// A stranger pasting the invisible MARKER into a comment must not suppress the
|
||||
// notice or become the grace-window clock.
|
||||
const commentsStub = (comments) => ({
|
||||
paginate: async () => comments,
|
||||
rest: { issues: { listComments: null } },
|
||||
});
|
||||
|
||||
const noticeCases = [
|
||||
['a user comment carrying MARKER is not a notice',
|
||||
[{ id: 1, user: { type: 'User' }, body: `sneaky ${MARKER}`, created_at: 'x' }], 0],
|
||||
['a bot comment carrying MARKER is a notice',
|
||||
[{ id: 2, user: { type: 'Bot' }, body: `${MARKER}\nnotice`, created_at: 'x' }], 1],
|
||||
['a bot comment without MARKER is not a notice',
|
||||
[{ id: 3, user: { type: 'Bot' }, body: 'unrelated', created_at: 'x' }], 0],
|
||||
['a user MARKER does not mask the real bot notice',
|
||||
[{ id: 4, user: { type: 'User' }, body: MARKER, created_at: 'x' },
|
||||
{ id: 5, user: { type: 'Bot' }, body: MARKER, created_at: 'y' }], 1],
|
||||
];
|
||||
|
||||
// --- runSweep: the stale-draft pass must honour every exemption ------------
|
||||
const draft = (over) => ({
|
||||
number: 9, draft: true, state: 'open', labels: [],
|
||||
user: { type: 'User', login: 'alice' },
|
||||
updated_at: new Date(Date.now() - 400 * 86400_000).toISOString(),
|
||||
...over,
|
||||
});
|
||||
|
||||
async function sweepClosed(pr, permission) {
|
||||
const closed = [];
|
||||
const github = {
|
||||
paginate: async (route) => (route === 'pulls' ? [pr] : []),
|
||||
rest: {
|
||||
pulls: {
|
||||
list: 'pulls',
|
||||
update: async ({ pull_number }) => closed.push(pull_number),
|
||||
},
|
||||
issues: { listComments: 'comments', createComment: async () => {} },
|
||||
repos: {
|
||||
getCollaboratorPermissionLevel: async () => {
|
||||
if (!permission) return notCollaborator();
|
||||
return { data: { permission } };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await runSweep({
|
||||
github, core: { info() {}, warning() {} },
|
||||
context: { repo: { owner: 'o', repo: 'r' } }, dryRun: false,
|
||||
});
|
||||
return closed;
|
||||
}
|
||||
|
||||
const sweepCases = [
|
||||
['stale draft from an outside author closes', draft({}), 1],
|
||||
['stale draft from a bot is left alone', draft({ user: { type: 'Bot' } }), 0],
|
||||
['stale draft from a writer is left alone', draft({}), 0, 'write'],
|
||||
[`stale draft with ${EXEMPT_LABEL} is left alone`, draft({ labels: [{ name: EXEMPT_LABEL }] }), 0],
|
||||
['recent draft is left alone', draft({ updated_at: new Date().toISOString() }), 0],
|
||||
];
|
||||
|
||||
(async () => {
|
||||
let failed = 0;
|
||||
for (const [name, comments, want] of noticeCases) {
|
||||
const got = (await findNotices({ github: commentsStub(comments), owner: 'o', repo: 'r', number: 1 })).length;
|
||||
if (got === want) console.log(` ok ${name}`);
|
||||
else { failed++; console.log(` FAIL ${name} -> ${got} notices, wanted ${want}`); }
|
||||
}
|
||||
for (const [name, pr, want, permission] of sweepCases) {
|
||||
const got = (await sweepClosed(pr, permission)).length;
|
||||
if (got === want) console.log(` ok ${name}`);
|
||||
else { failed++; console.log(` FAIL ${name} -> closed ${got}, wanted ${want}`); }
|
||||
}
|
||||
for (const [name, thunk, ok] of cases) {
|
||||
const result = await thunk();
|
||||
if (ok(result)) {
|
||||
console.log(` ok ${name}`);
|
||||
} else {
|
||||
failed++;
|
||||
console.log(` FAIL ${name} -> ${JSON.stringify(result)}`);
|
||||
}
|
||||
}
|
||||
assert.strictEqual(failed, 0, `${failed} case(s) failed`);
|
||||
console.log(`\n${cases.length + noticeCases.length + sweepCases.length} passed`);
|
||||
})();
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
name: Issue Gate
|
||||
|
||||
# Labels pull requests that are not linked to an issue carrying the
|
||||
# `maintainer-approved` label, and comments explaining how to fix it.
|
||||
#
|
||||
# This workflow never closes anything. `pr-sweeper.yml` re-checks later and closes
|
||||
# only after the grace period — that gives contributors time to link an issue, and
|
||||
# gives maintainers time to wave through a one-line fix. It is also the only thing
|
||||
# that can notice a sidebar issue link, which fires no webhook of its own.
|
||||
#
|
||||
# `pull_request_target` is required so the job has write access on pull requests
|
||||
# from forks. It must therefore NEVER run code from the pull request. The checkout
|
||||
# below is safe because on `pull_request_target` actions/checkout defaults to the
|
||||
# BASE ref, which is repo-trusted code. Never point it at `pr.head.sha`.
|
||||
#
|
||||
# Not triggered on `synchronize`: re-running on every push would be noise.
|
||||
# Drafts are ignored until marked ready.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const gate = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-gate.js`);
|
||||
await gate.runGate({ github, core, context });
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
name: PR Sweeper
|
||||
|
||||
# Deferred half of the issue gate. Every six hours:
|
||||
#
|
||||
# 1. Re-check every pull request carrying `needs-approved-issue`. Clear the ones
|
||||
# that now link an approved issue; close the ones still failing 72h after they
|
||||
# were told. The re-check is the point — linking an issue through the sidebar
|
||||
# fires no webhook, so `issue-gate.yml` never sees it.
|
||||
# 2. Close drafts from outside the org after 30 days without activity.
|
||||
#
|
||||
# Runs on `schedule`, so it never touches pull request code and needs none of the
|
||||
# `pull_request_target` precautions. Dispatch manually with dry_run to see what it
|
||||
# would do before it does it.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 */6 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Log intended actions without closing anything'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/github-script@v7
|
||||
env:
|
||||
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}
|
||||
with:
|
||||
script: |
|
||||
const gate = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-gate.js`);
|
||||
await gate.runSweep({ github, core, context, dryRun: process.env.DRY_RUN === 'true' });
|
||||
|
|
@ -26,3 +26,11 @@ jobs:
|
|||
run: uv sync --all-extras --dev
|
||||
- name: run basedpyright
|
||||
run: uv run basedpyright
|
||||
|
||||
issue-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# The gate runs from `pull_request_target`, where a crash is invisible until
|
||||
# a contributor's PR is silently ungated. Check it here instead.
|
||||
- run: node .github/scripts/issue-gate.test.js
|
||||
|
|
|
|||
|
|
@ -57,7 +57,10 @@ jobs:
|
|||
name: Run Unified Tests
|
||||
runs-on: ${{ fromJSON(format('[{0}]', needs.start-runner.outputs.runner-labels)) }}
|
||||
needs: start-runner
|
||||
if: needs.start-runner.outputs.runner-ready == 'true'
|
||||
# !cancelled() so this doesn't inherit gate's skip on push events.
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.start-runner.outputs.runner-ready == 'true'
|
||||
timeout-minutes: 90
|
||||
environment: unified-tests
|
||||
permissions:
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ api/docker-compose.yml
|
|||
*.db
|
||||
data
|
||||
redis-data
|
||||
docker-compose.yml
|
||||
compose.yml
|
||||
/docker-compose.yml
|
||||
/compose.yml
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
42
CHANGELOG.md
42
CHANGELOG.md
|
|
@ -11,6 +11,44 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
- Session responses now expose nullable `last_message_at`, backfilled and maintained from the newest message timestamp. `POST /v3/workspaces/{workspace_id}/sessions/list` accepts `sort_by=created_at|last_message_at` alongside the existing `reverse` parameter, with stable ID tie-breaking and sessions without messages placed last in either direction (#965).
|
||||
|
||||
## [3.1.0] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- Scopes: a named grouping of sessions that acts as a visibility boundary on recall, implemented as a facade over an observer peer (`scope.{name}` with `{"kind": "scope"}`). Developers manage them exclusively through `/v3/workspaces/{workspace_id}/scopes` (create-or-get, list, get, add/list/remove session membership) and an optional `scopes` field on session create — never through the observer/observed mechanics. Scope peers cannot author messages, cannot be a chat or representation `target`, are excluded from `peers.list` by default (`PeerGet.kind` = `"scope"` / `"all"` switches the view), and are rejected on the generic session-peer routes. Workspace-level key required; peer- and session-scoped keys get 401. Legacy peers occupying a reserved `scope.` name without the kind flag are refused with 409, never adopted (#884)
|
||||
- `scope` read option on chat, representation, session context, and workspace search. A single scope swaps the observer to the backing scope peer so conclusion recall, peer cards, and message tools stay inside that scope's membership. A list of scopes takes the union of member sessions (capped at `MAX_SESSION_ALLOWLIST_ENTRIES`) and executes via the session-allowlist path. Empty scopes fail closed. `scope` is mutually exclusive with `filters` and `session_id`. Workspace- or admin-level key required (403 otherwise). Scope peers are also rejected as `peer_target` / `peer_perspective` on session context and as the path peer or `target` on `GET /peers/{id}/context` (#897)
|
||||
- Scope backfill-by-copy and removal reconciliation. Adding a session that already has messages copies its explicit-level documents into the scope's collections (no LLM re-derivation; idempotent via `copied_from`). Removing a session soft-deletes those copies and fail-closed cascades to derived documents whose `source_ids` intersect anything removed, then enqueues a `card_refresh` dream with `rebuild=True` plus an omni dream. `GET /v3/workspaces/{workspace_id}/scopes/{scope_id}/status` reports per-session backfill state (`pending` / `completed` / `failed`, plus `docs_copied`) (#904)
|
||||
- Workspace-level chat at `POST /v3/workspaces/{workspace_id}/chat`: agentic dialectic over the whole workspace instead of a single (observer, observed) pair. Prefetches workspace stats and the top active peers' self cards, then searches pair-scoped memory with `[observer->observed]` attribution. Supports `session_id`, `scope`, `reasoning_level`, `response_format`, and SSE streaming (#931)
|
||||
- MCP workspace discovery: tools accept `workspace_id`, the worker honors an optional `X-Honcho-Workspace-ID` connection header, and `list_workspace` / `create_workspace` tools let clients pick or create a workspace instead of relying on the SDK default (#1020)
|
||||
- MCP `search` also queries conclusions in parallel with messages when `peer_id` is given, returning `{messages, conclusions}`. The conclusions leg degrades to `[]` on error so search never gets worse than before (#974)
|
||||
- Prometheus metrics for physical DB connections, visible even under `DB_POOL_CLASS=null`: `db_connections_open` (gauge) and `db_connections_established` (counter), hooked to SQLAlchemy connection-lifecycle events and registered on both the API and the deriver (#1055)
|
||||
- Bounded-label Prometheus series are zero-initialized at process start so an absent series means a broken scrape rather than "nothing happened" (#927)
|
||||
|
||||
### Changed
|
||||
|
||||
- Workspace and pair chat system prompts now describe Honcho, peers, and the harness on their own terms, and render only the tools the request actually offers. The pair prompt no longer advertises a write tool that is not in the loadout (#1066)
|
||||
- Deriver idle polling backoff is longer and no longer reset by periodic reconciler work, so downstream connection pools can cull idle DB connections (#1015)
|
||||
- LLM provider SDKs are lazy-loaded so idle API and deriver processes no longer pay for every provider at import time (#1011)
|
||||
- Production image is a multi-stage build: LanceDB/PyArrow move behind an optional `lancedb` extra (`INSTALL_LANCEDB=true` to restore them), FastAPI's unused cloud CLI is dropped, and the venv is copied into the runtime image with final ownership so Docker does not double the layer. Default unpacked image is about 663 MB (was 1.7 GB) (#1014)
|
||||
- Redis Cluster cache keys hash-tag the namespace so one deployment's keys land on a single shard instead of opening a connection to every node. No behaviour change on a non-cluster backend; existing keys age out by TTL (#1058)
|
||||
- Deriver extraction prompt no longer leaks its own few-shot examples into extracted conclusions (#1028)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Observer-scoped `get_observation_context` no longer materializes every session the observer has ever joined into a `session_name IN (...)` list (twice in one statement). Past ~32k sessions that hit psycopg's bind-parameter ceiling and 500'd. The observer half is now a correlated `EXISTS` over `session_peers`, two bind parameters regardless of membership size (#1065)
|
||||
- Re-adding an already-active session peer no longer advances `joined_at`, so `peer_perspective` search keeps messages from the original join. Genuine leave-and-rejoin still starts a new window (#1059)
|
||||
- Transient embedding-provider errors (for example an OpenAI-compatible 200 with empty `data: []`) were relabeled as token-limit errors. Only genuine oversize input raises `EmbeddingTokenLimitError`; other provider errors propagate unchanged (#791)
|
||||
- The filter DSL now fails closed with a 422 instead of a 500 on bad shapes, coerces operands by column type (so `{"session_id": {"ne": "abc"}}` is a string inequality rather than "invalid numeric"), and treats `NOT` / `ne` as null-safe (`IS NOT TRUE` / `IS DISTINCT FROM`) so negation no longer drops rows whose field is unset. Closed-set columns like `level` reject unknown values. Session-allowlist entries must be well-formed ids (`*` is 422, not a silent widen) (#947)
|
||||
- `ne` on JSONB metadata keys is null-safe: a missing key is not equal to the compared value, so `{"metadata": {"foo": {"ne": "bar"}}}` includes rows where `foo` is unset (#1036)
|
||||
- Oversized texts in `simple_batch_embed` are truncated to the embedding token cap instead of failing the whole batch. Representation processing reports failed observer saves in `RepresentationCompletedEvent` and raises when every observer save fails (#1019)
|
||||
- Assistant `reasoning_content` (DeepSeek / some OpenRouter models) is preserved across tool-loop turns. Previously the tool loop dropped thinking content before building the next assistant history message, so continuation requests failed. `reasoning_details` still takes precedence when both are present (#1034)
|
||||
- `create_observations` now honors `DERIVER_DEDUPLICATE` instead of hardcoding `deduplicate=True`, matching the representation write path (#1018)
|
||||
- `provider_params.timeout` is forwarded to the OpenAI-compatible embedding client, not just the LLM client (#1024)
|
||||
- Conclusions semantic-search validation errors name the field and the constraint instead of returning a generic 422 (#960)
|
||||
- OpenAI-compatible embedding calls request `encoding_format=float` so providers that default to base64 do not break pgvector inserts (#938)
|
||||
- Gemini batch embedding works for `gemini-embedding-2*` models, which rejected the previous request shape (#745)
|
||||
- MCP OAuth with no advertised scopes no longer defaults to read-only (which 403'd chat and search POSTs). Protected-resource metadata advertises read and write (#1004)
|
||||
|
||||
## [3.0.12] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
|
@ -182,7 +220,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
- Vector store queries no longer fetch embedding vectors — only document metadata is returned, reducing payload size and DB load (pgvector, lancedb, turbopuffer) (#682)
|
||||
- Langfuse trace metadata now includes `namespace`, `model`, and `provider` so traces can be filtered by deployment slice (#565)
|
||||
- Deriver: model-aware tokenizer (replaces the previously hardcoded encoding) and explicit guard on empty message content (#647)
|
||||
- Dialectic level defaults now merge correctly with per-level overrides in `src/config` (DEV-1733) (#656)
|
||||
- Dialectic level defaults now merge correctly with per-level overrides in `src/config` (#656)
|
||||
- Default dialectic tool choice switched from forced/required to `auto` (#630)
|
||||
- Vector sync given a substantial retry budget to tolerate transient embedding provider outages (#604)
|
||||
- `AgentToolConclusionsDeletedEvent` payload now carries `levels` for parity with the rest of the conclusion event surface (#612)
|
||||
|
|
@ -200,7 +238,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
- Removed strict parameter validation for thinking params on Anthropic and OpenAI transports — was rejecting valid per-transport configs (#686)
|
||||
- `reverse` query parameter is now honored on the v3 workspace list (`POST /v3/workspaces/list`), peer list (`POST /v3/workspaces/{workspace_id}/peers/list`), workspace-scoped session list (`POST /v3/workspaces/{workspace_id}/sessions/list`), and peer-scoped session list (`POST /v3/workspaces/{workspace_id}/peers/{peer_id}/sessions`). Honcho SDKs at 2.1.0+ were already sending `reverse=true` for these routes but the server silently ignored it. Ties on `created_at` now fall back to the internal nanoid `id` so ordering remains stable across pages (#685)
|
||||
- LLM client factories now receive `base_url` from `LLMSettings` for default providers — previously the override path honored `base_url` but the default path didn't, so operators pointing at OpenAI-compatible proxies via `LLM__OPENAI_BASE_URL` were ignored (#643, fixes #641)
|
||||
- Internal N+1 query in dialectic agent tool execution (DEV-1721) — collapsed per-iteration DB lookups into a single fetch (#652)
|
||||
- Internal N+1 query in dialectic agent tool execution — collapsed per-iteration DB lookups into a single fetch (#652)
|
||||
- Dreamer threshold and time-guard semantics: `check_and_schedule_dream` count filter now includes only `documents.level == 'explicit'` (dreamer-created levels are output, not input, and were inflating the threshold and creating a feedback loop); `last_dream_at` write relocated from `enqueue_dream` into `process_dream` so duplicate enqueues or failed runs no longer reset the 8-hour time guard (#573)
|
||||
- Deriver: blank observations are filtered out before embedding (previously triggered noisy embedding calls and persisted empty rows); blank-observation filtering unified across tool paths (#615)
|
||||
- Surprisal module: filter for level observations changed from `{"level": levels}` to `{"level": {"in": levels}}` — `apply_filter()` requires operator syntax, so the prior call silently returned 0 results and made the entire Surprisal phase of the Dream cycle a no-op (#581, fixes #559)
|
||||
|
|
|
|||
449
CONTRIBUTING.md
449
CONTRIBUTING.md
|
|
@ -1,215 +1,372 @@
|
|||
# Contributing to Honcho
|
||||
|
||||
Thank you for your interest in contributing to Honcho! This guide outlines the process for contributing to the project and our development conventions.
|
||||
<!-- This file is mirrored at docs/v3/contributing/guidelines.mdx. Update both. -->
|
||||
|
||||
## Getting Started
|
||||
Thanks for your interest in contributing. This guide covers how work gets accepted, how
|
||||
Honcho is put together, and what a mergeable pull request looks like.
|
||||
|
||||
Before you start contributing, please:
|
||||
Honcho is a small team maintaining a project that gets more proposals than we can review.
|
||||
The rules below exist so that the work you do has somewhere to land — not to keep you out.
|
||||
|
||||
1. **Set up your development environment** - Follow the [Local Development guide](./README.md#local-development) in the README to get Honcho running locally.
|
||||
## Contents
|
||||
|
||||
2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions.
|
||||
- [Before you write code](#before-you-write-code)
|
||||
- [What gets prioritized](#what-gets-prioritized)
|
||||
- [If you're an agent](#if-youre-an-agent)
|
||||
- [How Honcho works](#how-honcho-works)
|
||||
- [Where to change what](#where-to-change-what)
|
||||
- [Local setup](#local-setup)
|
||||
- [Making the change](#making-the-change)
|
||||
- [Opening the pull request](#opening-the-pull-request)
|
||||
- [Reporting bugs and requesting features](#reporting-bugs-and-requesting-features)
|
||||
- [Security](#security)
|
||||
- [License](#license)
|
||||
|
||||
3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to.
|
||||
## Before you write code
|
||||
|
||||
## Contribution Workflow
|
||||
**Every pull request needs an issue, and that issue needs the `maintainer-approved` label.**
|
||||
|
||||
### 1. Fork and Clone
|
||||
A pull request that is not linked to an approved issue gets labelled
|
||||
`needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one
|
||||
before it is closed automatically. Reopening costs nothing once the link is in place. This
|
||||
is automated. We do this because an unreviewable backlog helps nobody: a PR against an
|
||||
unapproved issue is work you did that we may not be able to merge, no matter how good it
|
||||
is.
|
||||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork locally:
|
||||
So, in order:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/honcho.git
|
||||
cd honcho
|
||||
```
|
||||
1. **Find approved work.** Browse
|
||||
[issues labelled `maintainer-approved`](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved).
|
||||
That label is the queue of things we have agreed should be built. Anything in it is fair
|
||||
game — comment on the issue to claim it.
|
||||
|
||||
3. Add the upstream repository as a remote:
|
||||
2. **Or open an issue and get it approved.** Use the
|
||||
[issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). Maintainers
|
||||
triage and apply the label.
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/plastic-labs/honcho.git
|
||||
```
|
||||
3. **If you feel strongly about an issue, come to [Discord](https://discord.gg/honcho).**
|
||||
This is the fastest path by a wide margin. Maintainers are more active there than in the
|
||||
issue tracker, and a five-minute conversation about what you want to build usually
|
||||
resolves whether it fits before either side spends real time on it.
|
||||
|
||||
### 2. Create a Branch
|
||||
4. **Then open the PR** and link the issue — either `Fixes #123` in the description, or
|
||||
**Development → link an issue** in the sidebar. Both work.
|
||||
|
||||
Create a new branch for your feature or bug fix:
|
||||
Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an
|
||||
obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the
|
||||
issue linkage.
|
||||
|
||||
## What gets prioritized
|
||||
|
||||
Roughly, work on Honcho falls along these axes. Knowing which one your idea sits on tells
|
||||
you a lot about how likely it is to get approved.
|
||||
|
||||
| Axis | What it covers |
|
||||
| --- | --- |
|
||||
| **Observability** | Understanding how Honcho behaves in production — telemetry, tracing, CloudEvents, metrics. |
|
||||
| **Memory quality** | Better conclusions from the same input — the deriver, dreamer, and dialectic; eval results. |
|
||||
| **Developer experience** | Fitting cleanly into more application architectures — SDKs, scopes, composable peers, the CLI. |
|
||||
| **Breadth of input** | Widening what Honcho can ingest and represent — multimodal and non-conversational data. |
|
||||
| **Ubiquity** | Reachable wherever a developer already works — integrations, self-hosting, alternate vector-store and inference backends, local-first defaults. |
|
||||
| **Reliability and cost** | Trustworthy in production — connection and concurrency hardening, queue throughput, cost per token. |
|
||||
|
||||
In practice, **Ubiquity** and **Developer experience** are where outside contributions land
|
||||
most easily. A new integration, a self-hosting rough edge, a vector-store or inference
|
||||
backend, an SDK ergonomics fix — these are additive and rarely collide with work already in
|
||||
flight.
|
||||
|
||||
Changes to the reasoning pipeline itself — deriver prompts, dialectic tool design, dreamer
|
||||
strategy — are the hardest to accept from outside. Not because they are unwelcome, but
|
||||
because they are measured against eval results we run internally, and they frequently
|
||||
conflict with in-flight work. Talk to us in Discord first, always.
|
||||
|
||||
## If you're an agent
|
||||
|
||||
If you are a coding agent working on this repository, read this section before writing code.
|
||||
The most common failure we see is a well-formed, well-tested pull request against an issue
|
||||
that was never approved. That gets closed, and the work is wasted.
|
||||
|
||||
- **Check the gate first.** Before writing code:
|
||||
|
||||
```bash
|
||||
gh issue view <N> --repo plastic-labs/honcho --json number,title,state,labels
|
||||
```
|
||||
|
||||
Stop if there is no issue number, if the issue is closed, or if `maintainer-approved` is
|
||||
not in the labels. Report that to the person you are working with instead of proceeding.
|
||||
|
||||
- **Do not open a PR in order to establish the issue link afterwards.** The issue comes
|
||||
first.
|
||||
|
||||
- **Do not report checks you did not run.** If you did not execute the test command, say so.
|
||||
A PR body claiming a green run that did not happen costs a maintainer more time than no
|
||||
claim at all.
|
||||
|
||||
- **Use the checklist.** [`skills/pre-pr/SKILL.md`](./skills/pre-pr/SKILL.md) in this repo
|
||||
encodes the gate, the test-layer matrix, and the PR body format. If your harness supports
|
||||
skills, invoke it rather than reimplementing the checks.
|
||||
|
||||
## How Honcho works
|
||||
|
||||
Enough architecture to find your way around. For the user-facing model — what a Peer is, what
|
||||
`get_context` returns — see [Core Concepts in the README](./README.md#core-concepts) and the
|
||||
[documentation](https://honcho.dev/docs/).
|
||||
|
||||
### Two processes
|
||||
|
||||
Honcho runs as two cooperating processes over a shared Postgres database and Redis cache.
|
||||
|
||||
| | API server | Deriver worker |
|
||||
| --- | --- | --- |
|
||||
| Start | `uv run fastapi dev src/main.py` | `uv run python -m src.deriver` |
|
||||
| Entry | `src/main.py` | `src/deriver/__main__.py` |
|
||||
| Does | Serves HTTP, enqueues background work, returns immediately | Consumes the queue: Deriver, Summarizer, Dreamer, Reconciler |
|
||||
| Hosts | The Dialectic agent, inline on the request path | Everything else |
|
||||
|
||||
The split is the load-bearing design decision: **an HTTP request never blocks on LLM work**,
|
||||
with the single exception of the Dialectic chat endpoint, which is synchronous by nature.
|
||||
If you are adding something slow, it belongs in the worker.
|
||||
|
||||
The deriver is a separate process. If messages go in and nothing ever comes out, the usual
|
||||
cause is that nobody started it.
|
||||
|
||||
### The path of a message
|
||||
|
||||
Worth tracing once, because it crosses most of the codebase:
|
||||
|
||||
1. `POST /v3/workspaces/{w}/sessions/{s}/messages` lands in `src/routers/messages.py`.
|
||||
2. The row is written, then `enqueue()` in `src/deriver/enqueue.py` creates `queue_item`
|
||||
rows — one set of work per observing peer.
|
||||
3. `src/deriver/queue_manager.py` polls the queue, claiming work units so that messages in a
|
||||
session are processed in order.
|
||||
4. `process_item()` in `src/deriver/consumer.py` dispatches on task type — representation,
|
||||
summary, deletion, reconciliation.
|
||||
5. For a representation task, `process_representation_tasks_batch()` in
|
||||
`src/deriver/deriver.py` makes **one structured-output LLM call for the whole batch** and
|
||||
writes the resulting conclusions into the collection keyed by the
|
||||
`(observer, observed)` peer pair.
|
||||
6. Later, `src/dialectic/` reads those conclusions back at recall time to answer a chat
|
||||
request.
|
||||
|
||||
Embedding is deliberately *not* on this path. `MessageEmbedding` rows are written with
|
||||
`sync_state='pending'` and embedded asynchronously by the Reconciler
|
||||
(`src/reconciler/sync_vectors.py`), which runs on a scheduler inside the deriver process.
|
||||
|
||||
### The four agents
|
||||
|
||||
They share tool definitions in `src/utils/agent_tools.py` and the provider-agnostic LLM
|
||||
client in `src/llm/`. Each has its own `MODEL_CONFIG` with a fallback chain in
|
||||
`src/config.py`.
|
||||
|
||||
| Agent | Where | Shape |
|
||||
| --- | --- | --- |
|
||||
| **Deriver** | `src/deriver/` | A single structured-output call per message batch. Not a tool loop — this is a deliberate cost and latency tradeoff. |
|
||||
| **Dialectic** | `src/dialectic/` | The one tool-using agent on the request path. Loops over tools until it can answer. Five reasoning tiers from `minimal` to `max`, each with its own model and tool set. |
|
||||
| **Dreamer** | `src/dreamer/` | Off-queue consolidation. Two specialist phases (deduction, then induction) that build reasoning trees over existing conclusions. |
|
||||
| **Summarizer** | `src/utils/summarizer.py` | Direct LLM call, no tools. Two tiers — short and long summaries at different message counts. |
|
||||
|
||||
Prompts live in `src/deriver/prompts.py`, `src/dialectic/prompts.py`, and
|
||||
`src/dreamer/specialists.py`.
|
||||
|
||||
### A note on naming
|
||||
|
||||
What the public API and documentation call **conclusions** are called **observations**
|
||||
throughout the code — `create_observations`, `get_observation_context`, and so on. Likewise
|
||||
**collections** and **documents** are internal storage concepts that are not exposed
|
||||
directly through the API. Do not rename across that boundary in a drive-by change; the
|
||||
public and internal vocabularies are being reconciled deliberately.
|
||||
|
||||
## Where to change what
|
||||
|
||||
| I want to change... | Start here |
|
||||
| --- | --- |
|
||||
| An HTTP endpoint | `src/routers/` — one module per resource |
|
||||
| A database query | `src/crud/` — mirrors the router layout |
|
||||
| The database schema | `src/models.py`, plus a migration in `migrations/versions/` |
|
||||
| A configuration value | `src/config.py`, and add it to `config.toml.example` and `.env.template` |
|
||||
| A tool an agent can call | `src/utils/agent_tools.py` — definitions plus the per-agent tool lists |
|
||||
| A prompt | `src/deriver/prompts.py`, `src/dialectic/prompts.py`, `src/dreamer/specialists.py` |
|
||||
| LLM provider behavior | `src/llm/backends/` — `anthropic.py`, `gemini.py`, `openai.py` |
|
||||
| Embeddings or vector storage | `src/embedding_client.py`, `src/vector_store/` |
|
||||
| Telemetry or metrics | `src/telemetry/` — see the notes in `CLAUDE.md` before adding an event type |
|
||||
| Authentication and scoping | `src/security.py`, `src/dependencies.py` |
|
||||
| The Python or TypeScript SDK | `sdks/python/`, `sdks/typescript/` |
|
||||
| The CLI | `honcho-cli/` |
|
||||
| The MCP server | `mcp/` |
|
||||
| Public documentation | `docs/v3/` — Mintlify; nav lives in `docs/docs.json` |
|
||||
|
||||
Tests in `tests/` mirror `src/`. `CLAUDE.md` at the repo root has more detail on house
|
||||
conventions, and is worth skimming even if you are not using an agent.
|
||||
|
||||
## Local setup
|
||||
|
||||
To run a personal instance, install the CLI (`uv tool install honcho-cli`) and then run `honcho start --setup` (Docker + an LLM provider key — not the Honcho API key from `honcho init`) — [CLI in the README](./README.md#cli).
|
||||
|
||||
To **develop this repo**, clone it and:
|
||||
|
||||
```bash
|
||||
uv sync # create the venv and install dependencies
|
||||
uv run alembic upgrade head # apply migrations
|
||||
```
|
||||
|
||||
Run both processes, in separate terminals:
|
||||
|
||||
```bash
|
||||
uv run fastapi dev src/main.py # API server, reloads on change
|
||||
uv run python -m src.deriver # background worker
|
||||
```
|
||||
|
||||
Everything Python goes through `uv run`. Redis is optional for local development; without it
|
||||
caching is simply disabled.
|
||||
|
||||
## Making the change
|
||||
|
||||
### Branches and commits
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# or
|
||||
git checkout -b fix/your-bug-fix-name
|
||||
```
|
||||
|
||||
**Branch naming conventions:**
|
||||
Prefixes: `feature/`, `fix/`, `docs/`, `refactor/`, `test/`.
|
||||
|
||||
- `feature/description` - for new features
|
||||
- `fix/description` - for bug fixes
|
||||
- `docs/description` - for documentation updates
|
||||
- `refactor/description` - for code refactoring
|
||||
- `test/description` - for adding or updating tests
|
||||
|
||||
### 3. Make Your Changes
|
||||
|
||||
- Write clean, readable code that follows our coding standards (see below)
|
||||
- Add tests for new functionality
|
||||
- Update documentation as needed
|
||||
- Make sure your changes don't break existing functionality
|
||||
|
||||
### 4. Commit Your Changes
|
||||
|
||||
We follow conventional commit standards. Format your commit messages as:
|
||||
|
||||
```
|
||||
type(scope): description
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
**Types:**
|
||||
|
||||
- `feat`: A new feature
|
||||
- `fix`: A bug fix
|
||||
- `docs`: Documentation only changes
|
||||
- `style`: Changes that do not affect the meaning of the code
|
||||
- `refactor`: A code change that neither fixes a bug nor adds a feature
|
||||
- `test`: Adding missing tests or correcting existing tests
|
||||
- `chore`: Changes to the build process or auxiliary tools
|
||||
|
||||
**Examples:**
|
||||
Commits follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by a
|
||||
`commit-msg` hook:
|
||||
|
||||
```bash
|
||||
git commit -m "feat(api): add new dialectic endpoint for user insights"
|
||||
git commit -m "fix(db): resolve connection pool timeout issue"
|
||||
git commit -m "docs(readme): update installation instructions"
|
||||
```
|
||||
|
||||
### 5. Submit a Pull Request
|
||||
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`.
|
||||
|
||||
1. Push your branch to your fork:
|
||||
### Pre-commit hooks
|
||||
|
||||
```bash
|
||||
git push origin your-branch-name
|
||||
```
|
||||
|
||||
2. Create a pull request on GitHub from your branch to the `main` branch
|
||||
|
||||
3. Fill out the pull request template with:
|
||||
- A clear description of what changes you've made
|
||||
- The motivation for the changes
|
||||
- Any relevant issue numbers (use "Closes #123" to auto-close issues)
|
||||
- Screenshots or examples if applicable
|
||||
|
||||
## Pre-commit Hooks
|
||||
|
||||
Honcho uses pre-commit hooks to enforce code quality and consistency. They run linting, formatting, type checking, and security scans before each commit.
|
||||
|
||||
### Installation
|
||||
Install them. CI runs the same checks, and it is much faster to find out locally.
|
||||
|
||||
```bash
|
||||
uv add --dev pre-commit
|
||||
uv run pre-commit install \
|
||||
--hook-type pre-commit \
|
||||
--hook-type commit-msg \
|
||||
--hook-type pre-push
|
||||
```
|
||||
|
||||
### What the hooks do
|
||||
At **commit** time: ruff lint and format, biome for TypeScript, basedpyright, bandit,
|
||||
markdownlint, and file hygiene. At **push** time: pytest, the alembic migration tests, and
|
||||
the SDK builds.
|
||||
|
||||
- **Code Quality** — Python linting and formatting (ruff), TypeScript linting (biome)
|
||||
- **Type Checking** — Static analysis with basedpyright
|
||||
- **Security** — Vulnerability scanning with bandit
|
||||
- **Documentation** — Markdown linting and license header checks
|
||||
- **Testing** — Automated test runs for Python and TypeScript
|
||||
- **File Hygiene** — Trailing whitespace, line endings, file size checks
|
||||
- **Commit Standards** — Conventional commit message validation
|
||||
That split matters — **a clean commit is not a clean push.** The test suite only runs at
|
||||
`pre-push`, so the first time you see test failures may be well after you thought you were
|
||||
done.
|
||||
|
||||
### Manual execution
|
||||
|
||||
Run against all files without committing:
|
||||
Run them by hand at any time:
|
||||
|
||||
```bash
|
||||
uv run pre-commit run --all-files
|
||||
uv run pre-commit run ruff --all-files
|
||||
```
|
||||
|
||||
Run a specific hook:
|
||||
Or the individual tools:
|
||||
|
||||
```bash
|
||||
uv run pre-commit run ruff --all-files
|
||||
uv run pre-commit run basedpyright --all-files
|
||||
uv run ruff check src/
|
||||
uv run ruff format src/
|
||||
uv run basedpyright
|
||||
```
|
||||
|
||||
## Coding Standards
|
||||
### Tests
|
||||
|
||||
### Python Code Style
|
||||
Write tests for new functionality, in the directory under `tests/` that mirrors the code you
|
||||
changed. Which layer you need depends on what you touched:
|
||||
|
||||
- Follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guidelines
|
||||
- Use [ruff](https://docs.astral.sh/ruff/) for linting and code formatting
|
||||
- Use type hints where possible
|
||||
- Write docstrings for functions and classes using Google style docstrings
|
||||
| What you changed | What to run |
|
||||
| --- | --- |
|
||||
| Anything in `src/` | Unit tests in the matching `tests/` tree — `uv run pytest tests/...` |
|
||||
| Deriver, dialectic, dreamer, or the LLM path | Unit tests, and consider `tests/live_llm/` (gated behind `--live-llm`) |
|
||||
| Queue behavior, config hierarchy, multi-turn flows, SDK contracts | `uv run python -m tests.unified.run` |
|
||||
| A `/v3` endpoint or deriver queue behavior | Actually run the stack and exercise it — not just pytest |
|
||||
| A migration | `uv run python scripts/run_alembic_tests.py`; every revision needs a test file |
|
||||
|
||||
### Code Organization
|
||||
|
||||
- Keep functions focused and single-purpose
|
||||
- Use meaningful variable and function names
|
||||
- Add comments for complex logic
|
||||
- Follow existing patterns in the codebase
|
||||
|
||||
### Testing
|
||||
|
||||
- Write unit tests for new functionality
|
||||
- Ensure existing tests pass before submitting
|
||||
- Use descriptive test names that explain what is being tested
|
||||
- Mock external dependencies appropriately
|
||||
The TypeScript SDK tests need a running server with a database and Redis, which pytest
|
||||
orchestrates. Run them with `uv run pytest tests/ -k typescript` from the repo root —
|
||||
`bun test` on its own will fail. To type-check the SDK alone:
|
||||
`cd sdks/typescript && bun run tsc --noEmit`.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update relevant documentation for new features
|
||||
- Include examples in docstrings where helpful
|
||||
- Keep README and other docs up to date with changes
|
||||
Update docs in the same PR when you change a public surface: `/v3` endpoints, SDK exports,
|
||||
or anything in `config.toml` / settings. Docs live in `docs/v3/`, and new pages need an entry
|
||||
in `docs/docs.json` or they will not appear in the nav.
|
||||
|
||||
## Review Process
|
||||
## Opening the pull request
|
||||
|
||||
1. **Automated checks** - Your PR will run through automated checks including tests and linting
|
||||
2. **Project maintainer review** - A project maintainer will review your code for:
|
||||
- Code quality and adherence to standards
|
||||
- Functionality and correctness
|
||||
- Test coverage
|
||||
- Documentation completeness
|
||||
3. **Discussion and iteration** - You may be asked to make changes or clarifications
|
||||
4. **Approval and merge** - Once approved, your PR will be merged into `main`
|
||||
### Leave "Allow edits by maintainers" checked
|
||||
|
||||
## Types of Contributions
|
||||
This is the single most useful thing you can do to get your PR merged quickly.
|
||||
|
||||
We welcome various types of contributions:
|
||||
Most contributor PRs arrive nearly right, needing a rename, a missing test, or a lint fix.
|
||||
If we can push that commit ourselves, it merges the same day. If we cannot, it becomes a
|
||||
review comment, and then we wait — sometimes for weeks — for a round trip on a two-line
|
||||
change.
|
||||
|
||||
- **Bug fixes** - Help us squash bugs and improve stability
|
||||
- **New features** - Add functionality that benefits the community
|
||||
- **Documentation** - Improve or expand our documentation
|
||||
- **Tests** - Increase test coverage and reliability
|
||||
- **Performance improvements** - Help make Honcho faster and more efficient
|
||||
- **Examples and tutorials** - Help other developers use Honcho
|
||||
GitHub checks the box by default when you fork. Leave it checked.
|
||||
|
||||
## Issue Reporting
|
||||
One caveat worth knowing: **the option does not exist on forks owned by an organization.**
|
||||
If you have the choice, fork from your personal account.
|
||||
|
||||
When reporting bugs or requesting features:
|
||||
### Fill out the template
|
||||
|
||||
1. Check if the issue already exists
|
||||
2. Use the appropriate issue template
|
||||
3. Provide clear reproduction steps for bugs
|
||||
4. Include relevant environment information
|
||||
5. Be specific about expected vs actual behavior
|
||||
`.github/pull_request_template.md` asks for a description, proofs, and the issue checkbox.
|
||||
|
||||
## Questions and Support
|
||||
"Proofs" means evidence the change works: the command you ran and its result, a log snippet,
|
||||
a screenshot, the failing case before and after. This is the section that most determines
|
||||
how fast your PR gets reviewed. Do not add sections to the template.
|
||||
|
||||
- **General questions** - Join our [Discord](http://discord.gg/honcho)
|
||||
- **Bug reports** - Use GitHub issues
|
||||
- **Feature requests** - Use GitHub issues with the feature request template
|
||||
- **Security issues** - Please email us privately rather than opening a public issue
|
||||
Link the issue so the gate can see it: `Fixes #123` in the description, or the
|
||||
**Development** section of the sidebar. The gate reads GitHub's own resolved issue links, so
|
||||
either route works — but a bare `#123` mention is only a reference and does not count.
|
||||
|
||||
### Review
|
||||
|
||||
1. Automated checks run — tests, linting, static analysis, and the issue gate.
|
||||
2. A maintainer reviews for correctness, test coverage, and fit with the surrounding code.
|
||||
`.github/CODEOWNERS` routes the request to whoever owns the area you touched.
|
||||
3. You may be asked for changes. Or we may just push them, if you left edits enabled.
|
||||
4. Once approved, we merge to `main`.
|
||||
|
||||
If a PR goes quiet, nudge us in [Discord](https://discord.gg/honcho).
|
||||
|
||||
## Reporting bugs and requesting features
|
||||
|
||||
Use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). There is
|
||||
one per kind of report, and picking the right one is most of what gets an issue triaged
|
||||
quickly:
|
||||
|
||||
- **Bug report** — something is broken or behaves incorrectly
|
||||
- **Memory / recall quality** — the deriver or dialectic returns poor, wrong, or missing context
|
||||
- **Feature request** — a new capability or API surface
|
||||
- **Integration request** — plugins, framework integrations, app-store listings
|
||||
- **Documentation issue** — anything wrong or missing in the docs
|
||||
- **General questions** — not an issue at all; ask in [Discord](https://discord.gg/honcho)
|
||||
|
||||
Before opening one, search existing issues, including closed ones.
|
||||
|
||||
A good bug report has the Honcho version or commit, whether you are self-hosted or on
|
||||
`api.honcho.dev`, the steps to reproduce, and what you expected instead. If it involves the
|
||||
deriver, logs from the worker process are usually the thing we ask for first.
|
||||
|
||||
**Redact before you post.** Issues are public, and Honcho stores conversational data — strip
|
||||
API keys, JWTs, and production user content out of any log or payload you attach.
|
||||
|
||||
## Security
|
||||
|
||||
Do not open a public issue for a suspected vulnerability. Report it privately through
|
||||
[GitHub Private Vulnerability Reporting](https://github.com/plastic-labs/honcho/security/advisories/new),
|
||||
which is the preferred channel, or by email. See [SECURITY.md](./SECURITY.md) for what to
|
||||
include, and note that Honcho does not operate a bug bounty.
|
||||
|
||||
## License
|
||||
|
||||
By contributing to Honcho, you agree that your contributions will be licensed under the same [AGPL-3.0 License](./LICENSE) that covers the project.
|
||||
By contributing to Honcho, you agree that your contributions will be licensed under the same
|
||||
[AGPL-3.0 License](./LICENSE) that covers the project.
|
||||
|
||||
Thank you for helping make Honcho better! 🫡
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ COPY --chown=app:app migrations/ /app/migrations/
|
|||
COPY --chown=app:app scripts/ /app/scripts/
|
||||
COPY --chown=app:app docker/ /app/docker/
|
||||
COPY --chown=app:app alembic.ini /app/alembic.ini
|
||||
# src/_version.py reads the service version from here at runtime, so this
|
||||
# is a runtime input as well as a build input.
|
||||
COPY --chown=app:app pyproject.toml /app/pyproject.toml
|
||||
# Copy config files - this will copy config.toml if it exists, and config.toml.example
|
||||
COPY --chown=app:app config.toml* /app/
|
||||
|
||||
|
|
|
|||
140
README.md
140
README.md
|
|
@ -8,14 +8,15 @@
|
|||
|
||||
---
|
||||
|
||||

|
||||

|
||||
[](https://pypi.org/project/honcho-ai/)
|
||||
[](https://npmjs.org/package/@honcho-ai/sdk)
|
||||
[](https://pypi.org/project/honcho-cli/)
|
||||
[](https://discord.gg/honcho)
|
||||
|
||||
**Honcho is memory infrastructure for building stateful agents that understand changing people, agents, groups, projects, and ideas over time.**
|
||||
|
||||
Store messages and events, let Honcho reason in the background, then query peer representations, session context, search results, or natural-language insights from any model or framework. Use it managed at [api.honcho.dev](https://api.honcho.dev) or self-host the FastAPI server yourself.
|
||||
Store messages and events, let Honcho reason in the background, then query peer representations, session context, search results, or natural-language insights from any model or framework. Use it managed at [api.honcho.dev](https://api.honcho.dev), run a local stack with [`honcho start`](#cli), or self-host the FastAPI server yourself.
|
||||
|
||||
Using Honcho as your memory system will earn your agents higher retention, more trust, and help you build data moats to out-compete incumbents.
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ Using Honcho as your memory system will earn your agents higher retention, more
|
|||
- [Quickstart](#quickstart)
|
||||
- [What Honcho Gives You](#what-honcho-gives-you)
|
||||
- [Integrations](#integrations)
|
||||
- [CLI](#cli)
|
||||
- [Core Concepts](#core-concepts)
|
||||
- [Benchmarks & Evals](#benchmarks--evals)
|
||||
- [Self-hosting](#self-hosting)
|
||||
|
|
@ -39,7 +41,7 @@ Using Honcho as your memory system will earn your agents higher retention, more
|
|||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
The Honcho project is split between several repositories, with this one hosting the core service logic — implemented as a FastAPI server. Client SDKs for Python and TypeScript live in the [`sdks/`](./sdks) directory.
|
||||
The Honcho project is split between several repositories, with this one hosting the core service logic — implemented as a FastAPI server. Client SDKs for Python and TypeScript live in the [`sdks/`](./sdks) directory. The [`honcho-cli`](./honcho-cli) package lives here too.
|
||||
|
||||
## Start Here
|
||||
|
||||
|
|
@ -47,7 +49,9 @@ The Honcho project is split between several repositories, with this one hosting
|
|||
| -------------------------------------- | ---------------------------------------------------------- | ----------------------------- |
|
||||
| Give my coding agent persistent memory | Claude Code, OpenCode, OpenClaw, Hermes, or any MCP client | [Integrations](#integrations) |
|
||||
| Add memory to my product | Python or TypeScript SDK | [Quickstart](#quickstart) |
|
||||
| Self-host Honcho | Docker / local development | [Self-hosting](#self-hosting) |
|
||||
| Run Honcho locally | Install CLI, then `honcho start --setup` | [CLI](#cli) |
|
||||
| Inspect a deployment | `honcho workspace inspect`, `honcho doctor` | [CLI](#cli) |
|
||||
| Self-host from source | Docker Compose or local development | [Self-hosting](#self-hosting) |
|
||||
|
||||
## Why Honcho
|
||||
|
||||
|
|
@ -56,7 +60,7 @@ The Honcho project is split between several repositories, with this one hosting
|
|||
| Reasoning-first memory | Extracts conclusions from conversations and events, not just matching chunks. |
|
||||
| Peer-centric model | Tracks users, agents, groups, projects, and ideas as entities that change over time. |
|
||||
| Multi-peer perspective | Models what one peer knows about another when configured. |
|
||||
| Managed or self-hosted | Use `api.honcho.dev` or run the FastAPI server yourself. |
|
||||
| Managed or self-hosted | Use `api.honcho.dev`, `honcho start` locally, or run the FastAPI server yourself. |
|
||||
| Agent-tool integrations | MCP, Claude Code, OpenCode, OpenClaw, Hermes, Cursor-compatible clients. |
|
||||
|
||||
## The Honcho Loop
|
||||
|
|
@ -70,7 +74,7 @@ Concretely: workspaces hold peers, peers participate in sessions, messages live
|
|||
|
||||
## Quickstart
|
||||
|
||||
Get an API key at [app.honcho.dev](https://app.honcho.dev) — when you sign up you'll be prompted to join an organization, which gets its own dedicated Honcho instance and $100 free credits. Or [self-host](#self-hosting) and run against `http://localhost:8000`.
|
||||
Get an API key at [app.honcho.dev](https://app.honcho.dev) — when you sign up you'll be prompted to join an organization, which gets its own dedicated Honcho instance and $100 free credits. Or install the CLI and run [`honcho start --setup`](#cli), then point the SDK at `http://localhost:8000`.
|
||||
|
||||
### Python
|
||||
|
||||
|
|
@ -226,12 +230,27 @@ For wiring the Honcho SDK into an existing application, install the integration
|
|||
npx skills add plastic-labs/honcho
|
||||
```
|
||||
|
||||
Then invoke `/honcho-integration` in Claude Code (or `/honcho-dev:integrate` via the plugin marketplace). The same command also installs the memory skills — `honcho-memory` (concepts: the recall/record loop, session and peer strategy, plus how to connect and drive an MCP-connected Honcho) and `honcho-cli` (inspecting and debugging a deployment). Details: [agentic development guide](https://honcho.dev/docs/v3/documentation/introduction/vibecoding).
|
||||
Then invoke `/honcho-integration` in Claude Code (or `/honcho-dev:integrate` via the plugin marketplace). The same command also installs the memory skills — `honcho-memory` (concepts: the recall/record loop, session and peer strategy, plus how to connect and drive an MCP-connected Honcho) and `honcho-cli` (inspecting a deployment, or running a local stack with `honcho start`). Details: [agentic development guide](https://honcho.dev/docs/v3/documentation/introduction/vibecoding).
|
||||
|
||||
### Other MCP clients
|
||||
|
||||
The same `claude mcp add` form (or its client-specific equivalent) works in any MCP-compatible client. See [MCP guide](https://honcho.dev/docs/v3/guides/integrations/mcp).
|
||||
|
||||
## CLI
|
||||
|
||||
[`honcho-cli`](https://pypi.org/project/honcho-cli/) inspects a Honcho deployment from the terminal, or runs a personal local stack with Docker.
|
||||
|
||||
```bash
|
||||
uv tool install honcho-cli
|
||||
honcho init # Honcho API key or browser login + server URL
|
||||
honcho start --setup basic # local stack: LLM provider key + Docker
|
||||
honcho doctor
|
||||
```
|
||||
|
||||
`honcho init` authenticates the CLI against a Honcho server. `honcho start --setup` is a separate step: it writes the LLM provider key the local deriver needs and starts API + deriver + Postgres + Redis.
|
||||
|
||||
Full commands and local-stack details: [CLI reference](https://honcho.dev/docs/v3/documentation/reference/cli) · [`honcho-cli/README.md`](./honcho-cli/README.md). To develop the server from source, see [Self-hosting](#self-hosting).
|
||||
|
||||
## Core Concepts
|
||||
|
||||
Honcho organises everything around **peers** — humans and AI agents alike are first-class entities. The peer model enables:
|
||||
|
|
@ -246,6 +265,7 @@ Peers exchange messages within sessions; Honcho reasons over those messages to b
|
|||
- **Workspace** (formerly App): top-level container; isolates data between use cases.
|
||||
- **Peer** (formerly User): any participant — human user or AI agent.
|
||||
- **Session**: a conversation context; many-to-many with peers.
|
||||
- **Scope**: a named grouping of sessions that bounds recall (chat, representation, search) to those members.
|
||||
- **Message**: an atomic data unit (peer-to-peer communication or ingested document chunk).
|
||||
|
||||
What you query out of Honcho:
|
||||
|
|
@ -274,9 +294,9 @@ Honcho's evals span LongMemEval, LoCoMo, and other long-conversation benchmarks.
|
|||
|
||||
## Self-hosting
|
||||
|
||||
Honcho is open source under AGPL-3.0. You can run the full server locally with Docker, then point the SDKs at `http://localhost:8000`.
|
||||
Honcho is open source under AGPL-3.0. To **run** a personal instance, install the CLI (`uv tool install honcho-cli`) and then [`honcho start --setup`](#cli). The paths below are for building from source, contributing, or deploying without the CLI.
|
||||
|
||||
### Quick start (Docker)
|
||||
### Quick start (from source, Docker)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/plastic-labs/honcho.git
|
||||
|
|
@ -458,79 +478,19 @@ Contributors: see [`CONTRIBUTING.md`](./CONTRIBUTING.md) for pre-commit setup. D
|
|||
|
||||
Honcho uses a flexible configuration system that supports both TOML files and environment variables. Configuration values are loaded in priority order: **environment variables > `.env` file > `config.toml` > defaults**.
|
||||
|
||||
<!-- markdownlint-disable MD033 -->
|
||||
<details>
|
||||
<summary>Full configuration reference</summary>
|
||||
|
||||
### Using config.toml
|
||||
|
||||
Copy the example configuration file to get started:
|
||||
Copy the example file to get started:
|
||||
|
||||
```bash
|
||||
cp config.toml.example config.toml
|
||||
```
|
||||
|
||||
Then modify the values as needed. The TOML file is organized into sections:
|
||||
The file is organized by subsystem — `[app]`, `[db]`, `[auth]`, `[cache]`, `[llm]`, `[deriver]`, `[dialectic]`, `[summary]`, `[dream]`, `[peer_card]`, `[webhook]`, `[metrics]`, `[telemetry]`, `[vector_store]`, and `[sentry]`. Any value can be overridden by an environment variable named `{SECTION}_{KEY}`, using `__` for nesting (`DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL`), or just `{KEY}` for app-level settings.
|
||||
|
||||
- `[app]` - Application-level settings (log level, session limits, embedding settings, namespace)
|
||||
- `[db]` - Database connection and pool settings
|
||||
- `[auth]` - Authentication configuration
|
||||
- `[cache]` - Redis cache configuration
|
||||
- `[llm]` - LLM provider API keys and general settings
|
||||
- `[deriver]` - Background worker settings and representation configuration
|
||||
- `[peer_card]` - Peer card generation settings
|
||||
- `[dialectic]` - Chat Endpoint configuration with per-level reasoning settings
|
||||
- `[summary]` - Session summarization settings
|
||||
- `[dream]` - Dream processing configuration (including specialist models and surprisal settings)
|
||||
- `[webhook]` - Webhook configuration
|
||||
- `[metrics]` - Prometheus pull-based metrics
|
||||
- `[telemetry]` - CloudEvents telemetry for analytics
|
||||
- `[vector_store]` - Vector store configuration (pgvector, turbopuffer, or lancedb)
|
||||
- `[sentry]` - Error tracking and monitoring settings
|
||||
|
||||
### Using Environment Variables
|
||||
|
||||
All configuration values can be overridden using environment variables. The environment variable names follow this pattern:
|
||||
|
||||
- `{SECTION}_{KEY}` for top-level section settings
|
||||
- Use `__` inside `{KEY}` for nested settings
|
||||
- Just `{KEY}` for app-level settings
|
||||
|
||||
Examples:
|
||||
|
||||
- `DB_CONNECTION_URI` - Database connection string
|
||||
- `AUTH_JWT_SECRET` - JWT secret key
|
||||
- `DERIVER_MODEL_CONFIG__TRANSPORT` - Transport for the background deriver
|
||||
- `SUMMARY_MODEL_CONFIG__MODEL` - Summary model override
|
||||
- `DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL` - Model for low reasoning level
|
||||
- `LOG_LEVEL` - Application log level
|
||||
- `METRICS_ENABLED` - Enable Prometheus metrics
|
||||
- `TELEMETRY_ENABLED` - Enable CloudEvents telemetry
|
||||
|
||||
### Example
|
||||
|
||||
If you have this in `config.toml`:
|
||||
|
||||
```toml
|
||||
[db]
|
||||
CONNECTION_URI = "postgresql+psycopg://localhost/honcho_dev"
|
||||
POOL_SIZE = 10
|
||||
```
|
||||
|
||||
You can override just the connection URI in production:
|
||||
|
||||
```bash
|
||||
export DB_CONNECTION_URI="postgresql+psycopg://prod-server/honcho_prod"
|
||||
```
|
||||
|
||||
The application will use the production connection URI while keeping the pool size from config.toml.
|
||||
|
||||
</details>
|
||||
<!-- markdownlint-enable MD033 -->
|
||||
See the [configuration reference](https://honcho.dev/docs/v3/contributing/configuration) for every available option, and [`.env.template`](./.env.template) for an annotated list of environment variables.
|
||||
|
||||
## Architecture
|
||||
|
||||
Honcho splits into two services: **Storage** (workspaces, peers, sessions, messages, internal collections) and **Insights** (reasoning, conclusions, representations, summaries, the chat endpoint). Storage is synchronous via the API; Insights is asynchronous via a background queue consumed by the deriver worker process.
|
||||
Honcho splits into two services: **Storage** (workspaces, peers, sessions, scopes, messages, internal collections) and **Insights** (reasoning, conclusions, representations, summaries, the chat endpoint). Storage is synchronous via the API; Insights is asynchronous via a background queue consumed by the deriver worker process.
|
||||
|
||||
**Key features:**
|
||||
|
||||
|
|
@ -558,16 +518,18 @@ Workspaces
|
|||
│ ├── Sessions │
|
||||
│ └── (internal collections, keyed by observer/observed peer pair)
|
||||
│ │
|
||||
├── Scopes ←─────────────────┤ (many-to-many with sessions)
|
||||
│ │
|
||||
└── Sessions ←───────────────┤ (many-to-many)
|
||||
└── Sessions ←───────────────┤ (many-to-many with peers)
|
||||
├── Peers ───────────────┘
|
||||
└── Messages (session-level)
|
||||
```
|
||||
|
||||
**Relationship Details:**
|
||||
|
||||
- A **Workspace** contains multiple **Peers**.
|
||||
- A **Workspace** contains multiple **Peers** and **Scopes**.
|
||||
- **Peers** and **Sessions** have a many-to-many relationship (peers can participate in multiple sessions, sessions can have multiple peers).
|
||||
- **Scopes** and **Sessions** have a many-to-many relationship (a session can belong to several scopes; a scope groups many sessions).
|
||||
- **Messages** belong to a session and are labelled by their source peer.
|
||||
- **Internal collections** of vector-embedded **documents** are keyed by `(observer, observed)` peer pairs. They are not directly exposed via the API; the observations stored in them are exposed as **Conclusions**.
|
||||
|
||||
|
|
@ -591,6 +553,28 @@ This unified model enables complex multi-participant interactions.
|
|||
The `Session` object represents a set of interactions between `Peers` within a
|
||||
`Workspace`. Other applications may refer to this as a thread or conversation.
|
||||
Sessions can involve multiple peers with configurable observation settings.
|
||||
A session can optionally join one or more **Scopes** at creation, or later via
|
||||
the scopes API.
|
||||
|
||||
#### Scopes
|
||||
|
||||
A `Scope` is a named grouping of sessions inside a `Workspace`. It is a
|
||||
visibility boundary on recall: chat, representation, session context, and
|
||||
workspace search answered through a scope see only what happened in that
|
||||
scope's member sessions. The underlying peers keep their unified
|
||||
representations across everything they have participated in.
|
||||
|
||||
Developers manage scopes through the scopes API (`honcho.scope(...)` /
|
||||
`honcho.scopes()`) and an optional `scopes` field on session create — not
|
||||
through observer/observed configuration. Adding a session that already has
|
||||
messages copies its existing explicit conclusions into the scope (no
|
||||
re-derivation); removing one reconciles those copies back out. Query
|
||||
backfill progress with the scope `status` endpoint.
|
||||
|
||||
A single scope name answers from that scope's collection and card. A list of
|
||||
scopes restricts recall to the union of their member sessions. Empty scopes
|
||||
fail closed. `scope` is mutually exclusive with `session` / `filters` on the
|
||||
same read.
|
||||
|
||||
#### Messages
|
||||
|
||||
|
|
@ -668,6 +652,7 @@ For low-latency use cases, Honcho provides access to a `representation` endpoint
|
|||
|
||||
- **Python** — [`honcho-ai`](https://pypi.org/project/honcho-ai/) on PyPI · source in [`sdks/python/`](./sdks/python)
|
||||
- **TypeScript** — [`@honcho-ai/sdk`](https://www.npmjs.com/package/@honcho-ai/sdk) on npm · source in [`sdks/typescript/`](./sdks/typescript)
|
||||
- **CLI** — [`honcho-cli`](https://pypi.org/project/honcho-cli/) on PyPI · source in [`honcho-cli/`](./honcho-cli) · [CLI reference](https://honcho.dev/docs/v3/documentation/reference/cli)
|
||||
|
||||
SDKs are versioned independently of the server. Current SDK versions track each other; the server badge above reflects the deployed server version.
|
||||
|
||||
|
|
@ -676,11 +661,14 @@ See the [SDK Reference](https://honcho.dev/docs/v3/documentation/reference/sdk)
|
|||
## Learn More
|
||||
|
||||
- [Developer documentation](https://honcho.dev/docs/) — full API surface, guides, integrations.
|
||||
- [CLI reference](https://honcho.dev/docs/v3/documentation/reference/cli) — local stack, inspect/debug commands, scripting.
|
||||
- [Plastic Labs blog](https://blog.plasticlabs.ai/) — design philosophy and history of the project.
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions to Honcho! Please read our [Contributing Guide](./CONTRIBUTING.md) for details on our development process, coding conventions, and how to submit pull requests.
|
||||
We welcome contributions to Honcho. One thing to know before you start: **pull requests must be linked to an issue carrying the `maintainer-approved` label**, or they are closed automatically. [Browse the approved queue](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved), or make your case in [Discord](http://discord.gg/honcho) — that is where maintainers are most active.
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full process, an architecture walkthrough, and a map of where to change what. For vulnerabilities, see [SECURITY.md](./SECURITY.md) — note that Honcho does not operate a bug bounty.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
The `main` branch of this repo maps to the latest canary version of Honcho. To see which versions are supported please refer to the git tags in the repo or the [compatibility guide](https://honcho.dev/docs/changelog/compatibility-guide).
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Do not open a public issue for a suspected vulnerability. Report it privately through one of:
|
||||
|
||||
1. **[GitHub Private Vulnerability Reporting](https://github.com/plastic-labs/honcho/security/advisories/new)** — preferred; it keeps the report, our replies, and any fix coordinated in one place.
|
||||
2. Email [support@honcho.dev](mailto:support@honcho.dev) with `[SECURITY]` in the subject.
|
||||
|
||||
Include as much of the following as you have:
|
||||
|
||||
- **Version** — a git commit SHA, or the release tag you are running
|
||||
- **Deployment** — self-hosted or the managed service at `api.honcho.dev`
|
||||
- **Affected component** — API, deriver, dialectic, auth/JWT, an SDK, or the managed offering
|
||||
- **Reproduction** — the exact steps, requests, or script that trigger it
|
||||
- **Proof of concept** — the smallest thing that demonstrates the issue actually works
|
||||
- **Impact** — what an attacker gains, and what they need to already have to get it
|
||||
- **How you found it** — manual review, fuzzing, a scanner, or model-assisted analysis
|
||||
|
||||
Reports with a working proof of concept get looked at first. A report that only describes a
|
||||
theoretical problem is much slower for us to act on, because we have to build the repro
|
||||
ourselves before we can confirm anything.
|
||||
|
||||
Honcho stores conversational data and peer representations. **Do not attach production user
|
||||
content, API keys, or JWTs** to a report — if we need a sample, we will ask for a redacted
|
||||
one.
|
||||
|
||||
## Testing
|
||||
|
||||
Test against an instance you operate. Do not run security testing against `api.honcho.dev`
|
||||
or against any Honcho deployment that is not yours — self-hosting is a first-class path and
|
||||
takes a few minutes to set up — install the CLI (`uv tool install honcho-cli`) then run `honcho start --setup` (Docker + an LLM provider key), or see [Self-hosting](./README.md#self-hosting).
|
||||
|
||||
## What to Expect
|
||||
|
||||
We will acknowledge your report and tell you whether we consider it in scope. If it is, we
|
||||
will let you know when a fix ships.
|
||||
|
||||
We do not commit to a response SLA, we do not coordinate CVE assignment on request, and we
|
||||
do not operate a disclosure timeline you can hold us to. This is a small team.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
The following are not treated as vulnerabilities. Reports consisting only of these will be
|
||||
closed without a detailed response:
|
||||
|
||||
- Automated scanner output with no working proof of concept
|
||||
- Model-generated findings that have not been verified by a human against a running instance
|
||||
- Missing security headers or TLS configuration with no demonstrated exploit
|
||||
- Rate limiting, or resource exhaustion with no demonstrated impact beyond your own instance
|
||||
- Vulnerabilities in dependencies with no demonstrated exploit path through Honcho
|
||||
- Configuration weaknesses that require an already-compromised host, or that come from
|
||||
deliberately insecure settings (for example running with `AUTH_USE_AUTH=false`, which is
|
||||
the documented local-development default and is not intended for a public deployment)
|
||||
- Social engineering, phishing, and physical access
|
||||
|
||||
For ordinary bugs, memory or recall quality problems, and feature requests, use the
|
||||
[issue templates](https://github.com/plastic-labs/honcho/issues/new/choose) instead.
|
||||
|
||||
## No Bug Bounty
|
||||
|
||||
The Honcho project does not offer any rewards for reported bugs or
|
||||
vulnerabilities. We do not aid security researchers to get such rewards for
|
||||
Honcho problems from other sources.
|
||||
|
||||
A bug bounty gives people too strong incentives to find and make up "problems"
|
||||
in bad faith that cause overload and abuse.
|
||||
|
||||
We still appreciate and value valid vulnerability reports.
|
||||
|
|
@ -10,14 +10,14 @@ This guide helps you match the right SDK version to your Honcho API version. New
|
|||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="TypeScript SDK" icon="js">
|
||||
**Latest:** v2.1.2
|
||||
**Latest:** v2.4.0
|
||||
|
||||
```bash
|
||||
npm install @honcho-ai/sdk
|
||||
```
|
||||
</Card>
|
||||
<Card title="Python SDK" icon="python">
|
||||
**Latest:** v2.1.2
|
||||
**Latest:** v2.4.0
|
||||
|
||||
```bash
|
||||
pip install honcho-ai
|
||||
|
|
@ -30,7 +30,9 @@ This guide helps you match the right SDK version to your Honcho API version. New
|
|||
|
||||
| Honcho API Version | TypeScript SDK | Python SDK |
|
||||
|-------------------|---------------|------------|
|
||||
| v3.0.11 (Current) | v2.1.2 | v2.1.2 |
|
||||
| v3.1.0 (Current) | v2.4.0 | v2.4.0 |
|
||||
| v3.0.12 | v2.3.0 | v2.3.0 |
|
||||
| v3.0.11 | v2.1.2 | v2.1.2 |
|
||||
| v3.0.10 | v2.1.2 | v2.1.2 |
|
||||
| v3.0.9 | v2.1.2 | v2.1.2 |
|
||||
| v3.0.8 | v2.1.2 | v2.1.2 |
|
||||
|
|
|
|||
|
|
@ -27,7 +27,45 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
|
|||
### Honcho API and SDK Changelogs
|
||||
<Tabs>
|
||||
<Tab title="Honcho API">
|
||||
<Update label="v3.0.12 (Current)">
|
||||
<Update label="v3.1.0 (Current)">
|
||||
### Added
|
||||
|
||||
- Scopes: a named grouping of sessions that acts as a visibility boundary on recall, implemented as a facade over an observer peer (`scope.{name}` with `{"kind": "scope"}`). Developers manage them exclusively through `/v3/workspaces/{workspace_id}/scopes` (create-or-get, list, get, add/list/remove session membership) and an optional `scopes` field on session create — never through the observer/observed mechanics. Scope peers cannot author messages, cannot be a chat or representation `target`, are excluded from `peers.list` by default (`PeerGet.kind` = `"scope"` / `"all"` switches the view), and are rejected on the generic session-peer routes. Workspace-level key required; peer- and session-scoped keys get 401. Legacy peers occupying a reserved `scope.` name without the kind flag are refused with 409, never adopted (#884)
|
||||
- `scope` read option on chat, representation, session context, and workspace search. A single scope swaps the observer to the backing scope peer so conclusion recall, peer cards, and message tools stay inside that scope's membership. A list of scopes takes the union of member sessions (capped at `MAX_SESSION_ALLOWLIST_ENTRIES`) and executes via the session-allowlist path. Empty scopes fail closed. `scope` is mutually exclusive with `filters` and `session_id`. Workspace- or admin-level key required (403 otherwise). Scope peers are also rejected as `peer_target` / `peer_perspective` on session context and as the path peer or `target` on `GET /peers/{id}/context` (#897)
|
||||
- Scope backfill-by-copy and removal reconciliation. Adding a session that already has messages copies its explicit-level documents into the scope's collections (no LLM re-derivation; idempotent via `copied_from`). Removing a session soft-deletes those copies and fail-closed cascades to derived documents whose `source_ids` intersect anything removed, then enqueues a `card_refresh` dream with `rebuild=True` plus an omni dream. `GET /v3/workspaces/{workspace_id}/scopes/{scope_id}/status` reports per-session backfill state (`pending` / `completed` / `failed`, plus `docs_copied`) (#904)
|
||||
- Workspace-level chat at `POST /v3/workspaces/{workspace_id}/chat`: agentic dialectic over the whole workspace instead of a single (observer, observed) pair. Prefetches workspace stats and the top active peers' self cards, then searches pair-scoped memory with `[observer->observed]` attribution. Supports `session_id`, `scope`, `reasoning_level`, `response_format`, and SSE streaming (#931)
|
||||
- MCP workspace discovery: tools accept `workspace_id`, the worker honors an optional `X-Honcho-Workspace-ID` connection header, and `list_workspace` / `create_workspace` tools let clients pick or create a workspace instead of relying on the SDK default (#1020)
|
||||
- MCP `search` also queries conclusions in parallel with messages when `peer_id` is given, returning `{messages, conclusions}`. The conclusions leg degrades to `[]` on error so search never gets worse than before (#974)
|
||||
- Prometheus metrics for physical DB connections, visible even under `DB_POOL_CLASS=null`: `db_connections_open` (gauge) and `db_connections_established` (counter), hooked to SQLAlchemy connection-lifecycle events and registered on both the API and the deriver (#1055)
|
||||
- Bounded-label Prometheus series are zero-initialized at process start so an absent series means a broken scrape rather than "nothing happened" (#927)
|
||||
|
||||
### Changed
|
||||
|
||||
- Workspace and pair chat system prompts now describe Honcho, peers, and the harness on their own terms, and render only the tools the request actually offers. The pair prompt no longer advertises a write tool that is not in the loadout (#1066)
|
||||
- Deriver idle polling backoff is longer and no longer reset by periodic reconciler work, so downstream connection pools can cull idle DB connections (#1015)
|
||||
- LLM provider SDKs are lazy-loaded so idle API and deriver processes no longer pay for every provider at import time (#1011)
|
||||
- Production image is a multi-stage build: LanceDB/PyArrow move behind an optional `lancedb` extra (`INSTALL_LANCEDB=true` to restore them), FastAPI's unused cloud CLI is dropped, and the venv is copied into the runtime image with final ownership so Docker does not double the layer. Default unpacked image is about 663 MB (was 1.7 GB) (#1014)
|
||||
- Redis Cluster cache keys hash-tag the namespace so one deployment's keys land on a single shard instead of opening a connection to every node. No behaviour change on a non-cluster backend; existing keys age out by TTL (#1058)
|
||||
- Deriver extraction prompt no longer leaks its own few-shot examples into extracted conclusions (#1028)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Observer-scoped `get_observation_context` no longer materializes every session the observer has ever joined into a `session_name IN (...)` list (twice in one statement). Past ~32k sessions that hit psycopg's bind-parameter ceiling and 500'd. The observer half is now a correlated `EXISTS` over `session_peers`, two bind parameters regardless of membership size (#1065)
|
||||
- Re-adding an already-active session peer no longer advances `joined_at`, so `peer_perspective` search keeps messages from the original join. Genuine leave-and-rejoin still starts a new window (#1059)
|
||||
- Transient embedding-provider errors (for example an OpenAI-compatible 200 with empty `data: []`) were relabeled as token-limit errors. Only genuine oversize input raises `EmbeddingTokenLimitError`; other provider errors propagate unchanged (#791)
|
||||
- The filter DSL now fails closed with a 422 instead of a 500 on bad shapes, coerces operands by column type (so `{"session_id": {"ne": "abc"}}` is a string inequality rather than "invalid numeric"), and treats `NOT` / `ne` as null-safe (`IS NOT TRUE` / `IS DISTINCT FROM`) so negation no longer drops rows whose field is unset. Closed-set columns like `level` reject unknown values. Session-allowlist entries must be well-formed ids (`*` is 422, not a silent widen) (#947)
|
||||
- `ne` on JSONB metadata keys is null-safe: a missing key is not equal to the compared value, so `{"metadata": {"foo": {"ne": "bar"}}}` includes rows where `foo` is unset (#1036)
|
||||
- Oversized texts in `simple_batch_embed` are truncated to the embedding token cap instead of failing the whole batch. Representation processing reports failed observer saves in `RepresentationCompletedEvent` and raises when every observer save fails (#1019)
|
||||
- Assistant `reasoning_content` (DeepSeek / some OpenRouter models) is preserved across tool-loop turns. Previously the tool loop dropped thinking content before building the next assistant history message, so continuation requests failed. `reasoning_details` still takes precedence when both are present (#1034)
|
||||
- `create_observations` now honors `DERIVER_DEDUPLICATE` instead of hardcoding `deduplicate=True`, matching the representation write path (#1018)
|
||||
- `provider_params.timeout` is forwarded to the OpenAI-compatible embedding client, not just the LLM client (#1024)
|
||||
- Conclusions semantic-search validation errors name the field and the constraint instead of returning a generic 422 (#960)
|
||||
- OpenAI-compatible embedding calls request `encoding_format=float` so providers that default to base64 do not break pgvector inserts (#938)
|
||||
- Gemini batch embedding works for `gemini-embedding-2*` models, which rejected the previous request shape (#745)
|
||||
- MCP OAuth with no advertised scopes no longer defaults to read-only (which 403'd chat and search POSTs). Protected-resource metadata advertises read and write (#1004)
|
||||
</Update>
|
||||
|
||||
<Update label="v3.0.12">
|
||||
### Added
|
||||
|
||||
- Session allowlist on the Dialectic and representation via a constrained `filters` body on `POST /peers/{peer_id}/chat` and `/representation`, supporting only the `session_id` key (a session id, a bare list, or `{"in": [...]}`). Unsupported keys and shapes are rejected with 422 rather than silently ignored, it composes with `session_id` (which must be included in the allowlist when both are given), and it is capped at 1,000 sessions per request. Enforcement is uniform and fail-closed at every recall chokepoint: scoped conclusion recall is restricted to `level == "explicit"` (dream-derived conclusions carry a single `session_name` but are synthesized across all sessions, so that stamp can't be scoped on), `get_reasoning_chain` is unavailable under an allowlist, and an empty allowlist short-circuits to empty results everywhere. Workspace keys pass the allowlist as-given; peer-scoped JWTs must be an active member of every allowlisted session (401 otherwise) (#882)
|
||||
|
|
@ -747,6 +785,17 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
|
|||
|
||||
<Tab title="Python SDK">
|
||||
[Python SDK](https://pypi.org/project/honcho-ai/)
|
||||
<Update label="v2.4.0 (Current)">
|
||||
### Added
|
||||
|
||||
- Scopes: `Honcho.scope()` / `HonchoAio.scope()` get-or-create a named visibility boundary, `Honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `Honcho.session(..., scopes=[...])` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
|
||||
- `scope` option on `Peer.chat()` / `chat_stream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`.
|
||||
- Workspace-level chat: `Honcho.chat()` / `HonchoAio.chat()` and `chat_stream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoning_level`, and `response_format` options as `Peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
|
||||
|
||||
### Changed
|
||||
|
||||
- `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair.
|
||||
</Update>
|
||||
<Update label="v2.3.0">
|
||||
### Added
|
||||
|
||||
|
|
@ -915,6 +964,17 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
|
|||
|
||||
<Tab title="TypeScript SDK">
|
||||
[TypeScript SDK](https://www.npmjs.com/package/@honcho-ai/sdk)
|
||||
<Update label="v2.4.0 (Current)">
|
||||
### Added
|
||||
|
||||
- Scopes: `honcho.scope()` get-or-creates a named visibility boundary, `honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `honcho.session({ scopes: [...] })` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
|
||||
- `scope` option on `peer.chat()` / `chatStream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`.
|
||||
- Workspace-level chat: `honcho.chat()` / `honcho.chatStream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoningLevel`, and `responseFormat` options as `peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
|
||||
|
||||
### Changed
|
||||
|
||||
- `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair.
|
||||
</Update>
|
||||
<Update label="v2.3.0">
|
||||
### Added
|
||||
|
||||
|
|
@ -1110,6 +1170,26 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
|
|||
</Tab>
|
||||
<Tab title="Honcho CLI">
|
||||
[Honcho CLI](https://pypi.org/project/honcho-cli/)
|
||||
<Update label="v0.1.4 (Current)">
|
||||
### Added
|
||||
|
||||
- A TTY notice when a newer `honcho-cli` is on PyPI (`uv tool upgrade honcho-cli`). Skipped in JSON mode; disable with `HONCHO_NO_UPDATE_CHECK`
|
||||
|
||||
### Fixed
|
||||
|
||||
- `--setup` for openai-compatible writes `EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL` into the profile `.env` alongside `LLM_OPENAI_BASE_URL` (#1068)
|
||||
- `--setup` API key prompts echo `*` per character so a paste is visibly received instead of a blank getpass field
|
||||
</Update>
|
||||
<Update label="v0.1.3">
|
||||
### Added
|
||||
|
||||
- `honcho start`, `honcho stop`, and `honcho status` — run a personal Honcho stack in Docker (API, deriver, Postgres, Redis). Profiles live under `~/.honcho/profiles/`. First start pins `ghcr.io/plastic-labs/honcho:latest` by digest and copies the image `config.toml`. Optional `--setup basic` / `--setup advanced` wizard writes LLM overrides to `.env` (#1029)
|
||||
- `honcho session view` — session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session (#1006)
|
||||
|
||||
### Fixed
|
||||
|
||||
- `honcho message list --last N` no longer stops at the first page of 50 — it walks pages to fill the requested window (#1006)
|
||||
</Update>
|
||||
<Update label="v0.1.2">
|
||||
### Added
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
"navigation": {
|
||||
"versions": [
|
||||
{
|
||||
"version": "v3.0.12",
|
||||
"version": "v3.1.0",
|
||||
"api": {
|
||||
"openapi": ["v3/openapi.json"]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -505,6 +505,69 @@ honcho session view [<session_id>]
|
|||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## honcho start
|
||||
|
||||
Start a local Honcho stack (API, deriver, Postgres, Redis).
|
||||
|
||||
Requires Docker. Uses cloud LLM providers. Does not change the CLI's
|
||||
configured server URL — pass HONCHO_BASE_URL to talk to this stack.
|
||||
``--setup basic`` or ``--setup advanced`` runs an interactive config wizard.
|
||||
|
||||
```bash
|
||||
honcho start
|
||||
```
|
||||
|
||||
<ParamField path="--profile" type="string" default="local">
|
||||
Local stack profile name.
|
||||
</ParamField>
|
||||
<ParamField path="--api-port" type="string">
|
||||
Host port for the API.
|
||||
</ParamField>
|
||||
<ParamField path="--db-port" type="string">
|
||||
Host port for Postgres.
|
||||
</ParamField>
|
||||
<ParamField path="--redis-port" type="string">
|
||||
Host port for Redis.
|
||||
</ParamField>
|
||||
<ParamField path="--setup" type="string">
|
||||
Interactive config wizard: basic (provider/model) or advanced (embeddings, deriver, dialectic, dreams, flush).
|
||||
</ParamField>
|
||||
<ParamField path="--image" type="string">
|
||||
Honcho image to pull and pin by digest (default: ghcr.io/plastic-labs/honcho:latest).
|
||||
</ParamField>
|
||||
<ParamField path="--timeout" type="string" default="180">
|
||||
Seconds to wait for /health after compose up.
|
||||
</ParamField>
|
||||
|
||||
## honcho status
|
||||
|
||||
Show local stack endpoints and container health.
|
||||
|
||||
With no ``--profile``, lists every stack under ``~/.honcho/profiles/``.
|
||||
|
||||
```bash
|
||||
honcho status
|
||||
```
|
||||
|
||||
<ParamField path="--profile" type="string">
|
||||
Limit to this profile. Omit to show every local stack.
|
||||
</ParamField>
|
||||
|
||||
## honcho stop
|
||||
|
||||
Stop the local stack started by `honcho start`. Keeps data unless --wipe.
|
||||
|
||||
```bash
|
||||
honcho stop
|
||||
```
|
||||
|
||||
<ParamField path="--profile" type="string" default="local">
|
||||
Local stack profile name.
|
||||
</ParamField>
|
||||
<ParamField path="--wipe" type="boolean">
|
||||
Also delete volumes (Postgres data).
|
||||
</ParamField>
|
||||
|
||||
## honcho workspace
|
||||
|
||||
List, create, inspect, delete, and search workspaces.
|
||||
|
|
|
|||
|
|
@ -5,13 +5,31 @@ icon: 'handshake'
|
|||
|
||||
Thank you for your interest in contributing to Honcho! This guide outlines the process for contributing to the project and our development conventions.
|
||||
|
||||
## Before you write code
|
||||
|
||||
**Every pull request needs an issue, and that issue needs the `maintainer-approved` label.**
|
||||
|
||||
A pull request that is not linked to an approved issue gets labelled `needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one before it is closed automatically. Reopening costs nothing once the link is in place. This is automated. We do this because an unreviewable backlog helps nobody: a PR against an unapproved issue is work you did that we may not be able to merge, no matter how good it is.
|
||||
|
||||
So, in order:
|
||||
|
||||
1. **Find approved work.** Browse [issues labelled `maintainer-approved`](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved). That label is the queue of things we have agreed should be built. Anything in it is fair game — comment on the issue to claim it.
|
||||
|
||||
2. **Or open an issue and get it approved.** Use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). Maintainers triage and apply the label.
|
||||
|
||||
3. **If you feel strongly about an issue, come to [Discord](https://discord.gg/honcho).** This is the fastest path by a wide margin. Maintainers are more active there than in the issue tracker, and a five-minute conversation about what you want to build usually resolves whether it fits before either side spends real time on it.
|
||||
|
||||
4. **Then open the PR** and link the issue — either `Fixes #123` in the description, or **Development → link an issue** in the sidebar. Both work.
|
||||
|
||||
Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the issue linkage.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Before you start contributing, please:
|
||||
|
||||
1. **Set up your development environment** - Follow the [Local Development guide](https://github.com/plastic-labs/honcho/blob/main/CONTRIBUTING.md#local-development) in the Honcho repository to get Honcho running locally.
|
||||
|
||||
2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions.
|
||||
2. **Join our community** - Feel free to join us in our [Discord](https://discord.gg/honcho) to discuss your changes, get help, or ask questions.
|
||||
|
||||
3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to.
|
||||
|
||||
|
|
@ -94,7 +112,7 @@ git commit -m "docs(readme): update installation instructions"
|
|||
3. Fill out the pull request template with:
|
||||
- A clear description of what changes you've made
|
||||
- The motivation for the changes
|
||||
- Any relevant issue numbers (use "Closes #123" to auto-close issues)
|
||||
- A link to the approved issue — `Fixes #123` in the description, or **Development → link an issue** in the sidebar. This is required; see [Before you write code](#before-you-write-code).
|
||||
- Screenshots or examples if applicable
|
||||
|
||||
## Coding Standards
|
||||
|
|
@ -128,7 +146,7 @@ git commit -m "docs(readme): update installation instructions"
|
|||
|
||||
## Review Process
|
||||
|
||||
1. **Automated checks** - Your PR will run through automated checks including tests and linting
|
||||
1. **Automated checks** - Your PR will run through automated checks including tests, linting, and the issue gate
|
||||
2. **Project maintainer review** - A project maintainer will review your code for:
|
||||
- Code quality and adherence to standards
|
||||
- Functionality and correctness
|
||||
|
|
@ -153,17 +171,21 @@ We welcome various types of contributions:
|
|||
When reporting bugs or requesting features:
|
||||
|
||||
1. Check if the issue already exists
|
||||
2. Use the appropriate issue template
|
||||
2. Use the appropriate [issue template](https://github.com/plastic-labs/honcho/issues/new/choose) (bug, memory/recall quality, feature, integration, or documentation)
|
||||
3. Provide clear reproduction steps for bugs
|
||||
4. Include relevant environment information
|
||||
4. Include relevant environment information (managed vs self-hosted, server version, SDK)
|
||||
5. Be specific about expected vs actual behavior
|
||||
6. Redact secrets, JWTs, and production user content
|
||||
|
||||
## Questions and Support
|
||||
|
||||
- **General questions** - Join our [Discord](http://discord.gg/honcho)
|
||||
- **Bug reports** - Use GitHub issues
|
||||
- **Feature requests** - Use GitHub issues with the feature request template
|
||||
- **Security issues** - Please email us privately rather than opening a public issue
|
||||
- **General questions** - Join our [Discord](https://discord.gg/honcho)
|
||||
- **Bug reports** - GitHub issues → Bug report template
|
||||
- **Memory / recall quality** - GitHub issues → Memory / recall quality template
|
||||
- **Feature requests** - GitHub issues → Feature request template
|
||||
- **Integrations / plugins / app-store listings** - GitHub issues → Integration request template
|
||||
- **Documentation issues** - GitHub issues → Documentation issue template
|
||||
- **Security issues** - Report **privately** only — see [`SECURITY.md`](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md) (GitHub Private Vulnerability Reporting or email). Do not open a public issue.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -3,170 +3,360 @@ title: 'Contributing Guidelines'
|
|||
icon: 'handshake'
|
||||
---
|
||||
|
||||
Thank you for your interest in contributing to Honcho! This guide outlines the process for contributing to the project and our development conventions.
|
||||
{/* This file mirrors CONTRIBUTING.md in the repo root. Update both. */}
|
||||
|
||||
## Getting Started
|
||||
Thanks for your interest in contributing. This guide covers how work gets accepted, how
|
||||
Honcho is put together, and what a mergeable pull request looks like.
|
||||
|
||||
Before you start contributing, please:
|
||||
Honcho is a small team maintaining a project that gets more proposals than we can review.
|
||||
The rules below exist so that the work you do has somewhere to land — not to keep you out.
|
||||
|
||||
1. **Set up your development environment** - Follow the [Local Development guide](https://github.com/plastic-labs/honcho/blob/main/CONTRIBUTING.md#local-development) in the Honcho repository to get Honcho running locally.
|
||||
## Before you write code
|
||||
|
||||
2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions.
|
||||
**Every pull request needs an issue, and that issue needs the `maintainer-approved` label.**
|
||||
|
||||
3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to.
|
||||
A pull request that is not linked to an approved issue gets labelled
|
||||
`needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one
|
||||
before it is closed automatically. Reopening costs nothing once the link is in place. This
|
||||
is automated. We do this because an unreviewable backlog helps nobody: a PR against an
|
||||
unapproved issue is work you did that we may not be able to merge, no matter how good it
|
||||
is.
|
||||
|
||||
## Contribution Workflow
|
||||
So, in order:
|
||||
|
||||
### 1. Fork and Clone
|
||||
1. **Find approved work.** Browse
|
||||
[issues labelled `maintainer-approved`](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved).
|
||||
That label is the queue of things we have agreed should be built. Anything in it is fair
|
||||
game — comment on the issue to claim it.
|
||||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/honcho.git
|
||||
cd honcho
|
||||
```
|
||||
3. Add the upstream repository as a remote:
|
||||
```bash
|
||||
git remote add upstream https://github.com/plastic-labs/honcho.git
|
||||
```
|
||||
2. **Or open an issue and get it approved.** Use the
|
||||
[issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). Maintainers
|
||||
triage and apply the label.
|
||||
|
||||
### 2. Create a Branch
|
||||
3. **If you feel strongly about an issue, come to [Discord](https://discord.gg/honcho).**
|
||||
This is the fastest path by a wide margin. Maintainers are more active there than in the
|
||||
issue tracker, and a five-minute conversation about what you want to build usually
|
||||
resolves whether it fits before either side spends real time on it.
|
||||
|
||||
Create a new branch for your feature or bug fix:
|
||||
4. **Then open the PR** and link the issue — either `Fixes #123` in the description, or
|
||||
**Development → link an issue** in the sidebar. Both work.
|
||||
|
||||
Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an
|
||||
obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the
|
||||
issue linkage.
|
||||
|
||||
## What gets prioritized
|
||||
|
||||
Roughly, work on Honcho falls along these axes. Knowing which one your idea sits on tells
|
||||
you a lot about how likely it is to get approved.
|
||||
|
||||
| Axis | What it covers |
|
||||
| --- | --- |
|
||||
| **Observability** | Understanding how Honcho behaves in production — telemetry, tracing, CloudEvents, metrics. |
|
||||
| **Memory quality** | Better conclusions from the same input — the deriver, dreamer, and dialectic; eval results. |
|
||||
| **Developer experience** | Fitting cleanly into more application architectures — SDKs, scopes, composable peers, the CLI. |
|
||||
| **Breadth of input** | Widening what Honcho can ingest and represent — multimodal and non-conversational data. |
|
||||
| **Ubiquity** | Reachable wherever a developer already works — integrations, self-hosting, alternate vector-store and inference backends, local-first defaults. |
|
||||
| **Reliability and cost** | Trustworthy in production — connection and concurrency hardening, queue throughput, cost per token. |
|
||||
|
||||
In practice, **Ubiquity** and **Developer experience** are where outside contributions land
|
||||
most easily. A new integration, a self-hosting rough edge, a vector-store or inference
|
||||
backend, an SDK ergonomics fix — these are additive and rarely collide with work already in
|
||||
flight.
|
||||
|
||||
Changes to the reasoning pipeline itself — deriver prompts, dialectic tool design, dreamer
|
||||
strategy — are the hardest to accept from outside. Not because they are unwelcome, but
|
||||
because they are measured against eval results we run internally, and they frequently
|
||||
conflict with in-flight work. Talk to us in Discord first, always.
|
||||
|
||||
## If you're an agent
|
||||
|
||||
If you are a coding agent working on this repository, read this section before writing code.
|
||||
The most common failure we see is a well-formed, well-tested pull request against an issue
|
||||
that was never approved. That gets closed, and the work is wasted.
|
||||
|
||||
- **Check the gate first.** Before writing code:
|
||||
|
||||
```bash
|
||||
gh issue view <N> --repo plastic-labs/honcho --json number,title,state,labels
|
||||
```
|
||||
|
||||
Stop if there is no issue number, if the issue is closed, or if `maintainer-approved` is
|
||||
not in the labels. Report that to the person you are working with instead of proceeding.
|
||||
|
||||
- **Do not open a PR in order to establish the issue link afterwards.** The issue comes
|
||||
first.
|
||||
|
||||
- **Do not report checks you did not run.** If you did not execute the test command, say so.
|
||||
A PR body claiming a green run that did not happen costs a maintainer more time than no
|
||||
claim at all.
|
||||
|
||||
- **Use the checklist.** [`skills/pre-pr/SKILL.md`](https://github.com/plastic-labs/honcho/blob/main/skills/pre-pr/SKILL.md) in this repo
|
||||
encodes the gate, the test-layer matrix, and the PR body format. If your harness supports
|
||||
skills, invoke it rather than reimplementing the checks.
|
||||
|
||||
## How Honcho works
|
||||
|
||||
Enough architecture to find your way around. For the user-facing model — what a Peer is, what
|
||||
`get_context` returns — see [Core Concepts](https://github.com/plastic-labs/honcho#core-concepts) and the
|
||||
[documentation](https://honcho.dev/docs/).
|
||||
|
||||
### Two processes
|
||||
|
||||
Honcho runs as two cooperating processes over a shared Postgres database and Redis cache.
|
||||
|
||||
| | API server | Deriver worker |
|
||||
| --- | --- | --- |
|
||||
| Start | `uv run fastapi dev src/main.py` | `uv run python -m src.deriver` |
|
||||
| Entry | `src/main.py` | `src/deriver/__main__.py` |
|
||||
| Does | Serves HTTP, enqueues background work, returns immediately | Consumes the queue: Deriver, Summarizer, Dreamer, Reconciler |
|
||||
| Hosts | The Dialectic agent, inline on the request path | Everything else |
|
||||
|
||||
The split is the load-bearing design decision: **an HTTP request never blocks on LLM work**,
|
||||
with the single exception of the Dialectic chat endpoint, which is synchronous by nature.
|
||||
If you are adding something slow, it belongs in the worker.
|
||||
|
||||
The deriver is a separate process. If messages go in and nothing ever comes out, the usual
|
||||
cause is that nobody started it.
|
||||
|
||||
### The path of a message
|
||||
|
||||
Worth tracing once, because it crosses most of the codebase:
|
||||
|
||||
1. `POST /v3/workspaces/{w}/sessions/{s}/messages` lands in `src/routers/messages.py`.
|
||||
2. The row is written, then `enqueue()` in `src/deriver/enqueue.py` creates `queue_item`
|
||||
rows — one set of work per observing peer.
|
||||
3. `src/deriver/queue_manager.py` polls the queue, claiming work units so that messages in a
|
||||
session are processed in order.
|
||||
4. `process_item()` in `src/deriver/consumer.py` dispatches on task type — representation,
|
||||
summary, deletion, reconciliation.
|
||||
5. For a representation task, `process_representation_tasks_batch()` in
|
||||
`src/deriver/deriver.py` makes **one structured-output LLM call for the whole batch** and
|
||||
writes the resulting conclusions into the collection keyed by the
|
||||
`(observer, observed)` peer pair.
|
||||
6. Later, `src/dialectic/` reads those conclusions back at recall time to answer a chat
|
||||
request.
|
||||
|
||||
Embedding is deliberately *not* on this path. `MessageEmbedding` rows are written with
|
||||
`sync_state='pending'` and embedded asynchronously by the Reconciler
|
||||
(`src/reconciler/sync_vectors.py`), which runs on a scheduler inside the deriver process.
|
||||
|
||||
### The four agents
|
||||
|
||||
They share tool definitions in `src/utils/agent_tools.py` and the provider-agnostic LLM
|
||||
client in `src/llm/`. Each has its own `MODEL_CONFIG` with a fallback chain in
|
||||
`src/config.py`.
|
||||
|
||||
| Agent | Where | Shape |
|
||||
| --- | --- | --- |
|
||||
| **Deriver** | `src/deriver/` | A single structured-output call per message batch. Not a tool loop — this is a deliberate cost and latency tradeoff. |
|
||||
| **Dialectic** | `src/dialectic/` | The one tool-using agent on the request path. Loops over tools until it can answer. Five reasoning tiers from `minimal` to `max`, each with its own model and tool set. |
|
||||
| **Dreamer** | `src/dreamer/` | Off-queue consolidation. Two specialist phases (deduction, then induction) that build reasoning trees over existing conclusions. |
|
||||
| **Summarizer** | `src/utils/summarizer.py` | Direct LLM call, no tools. Two tiers — short and long summaries at different message counts. |
|
||||
|
||||
Prompts live in `src/deriver/prompts.py`, `src/dialectic/prompts.py`, and
|
||||
`src/dreamer/specialists.py`.
|
||||
|
||||
### A note on naming
|
||||
|
||||
What the public API and documentation call **conclusions** are called **observations**
|
||||
throughout the code — `create_observations`, `get_observation_context`, and so on. Likewise
|
||||
**collections** and **documents** are internal storage concepts that are not exposed
|
||||
directly through the API. Do not rename across that boundary in a drive-by change; the
|
||||
public and internal vocabularies are being reconciled deliberately.
|
||||
|
||||
## Where to change what
|
||||
|
||||
| I want to change... | Start here |
|
||||
| --- | --- |
|
||||
| An HTTP endpoint | `src/routers/` — one module per resource |
|
||||
| A database query | `src/crud/` — mirrors the router layout |
|
||||
| The database schema | `src/models.py`, plus a migration in `migrations/versions/` |
|
||||
| A configuration value | `src/config.py`, and add it to `config.toml.example` and `.env.template` |
|
||||
| A tool an agent can call | `src/utils/agent_tools.py` — definitions plus the per-agent tool lists |
|
||||
| A prompt | `src/deriver/prompts.py`, `src/dialectic/prompts.py`, `src/dreamer/specialists.py` |
|
||||
| LLM provider behavior | `src/llm/backends/` — `anthropic.py`, `gemini.py`, `openai.py` |
|
||||
| Embeddings or vector storage | `src/embedding_client.py`, `src/vector_store/` |
|
||||
| Telemetry or metrics | `src/telemetry/` — see the notes in `CLAUDE.md` before adding an event type |
|
||||
| Authentication and scoping | `src/security.py`, `src/dependencies.py` |
|
||||
| The Python or TypeScript SDK | `sdks/python/`, `sdks/typescript/` |
|
||||
| The CLI | `honcho-cli/` |
|
||||
| The MCP server | `mcp/` |
|
||||
| Public documentation | `docs/v3/` — Mintlify; nav lives in `docs/docs.json` |
|
||||
|
||||
Tests in `tests/` mirror `src/`. `CLAUDE.md` at the repo root has more detail on house
|
||||
conventions, and is worth skimming even if you are not using an agent.
|
||||
|
||||
## Local setup
|
||||
|
||||
To run a personal instance, install the CLI (`uv tool install honcho-cli`) and then run
|
||||
`honcho start --setup` (Docker + an LLM provider key) — [CLI reference](/v3/documentation/reference/cli).
|
||||
|
||||
To **develop this repo**, clone it and:
|
||||
|
||||
```bash
|
||||
uv sync # create the venv and install dependencies
|
||||
uv run alembic upgrade head # apply migrations
|
||||
```
|
||||
|
||||
Run both processes, in separate terminals:
|
||||
|
||||
```bash
|
||||
uv run fastapi dev src/main.py # API server, reloads on change
|
||||
uv run python -m src.deriver # background worker
|
||||
```
|
||||
|
||||
Everything Python goes through `uv run`. Redis is optional for local development; without it
|
||||
caching is simply disabled.
|
||||
|
||||
## Making the change
|
||||
|
||||
### Branches and commits
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# or
|
||||
git checkout -b fix/your-bug-fix-name
|
||||
```
|
||||
|
||||
**Branch naming conventions:**
|
||||
- `feature/description` - for new features
|
||||
- `fix/description` - for bug fixes
|
||||
- `docs/description` - for documentation updates
|
||||
- `refactor/description` - for code refactoring
|
||||
- `test/description` - for adding or updating tests
|
||||
Prefixes: `feature/`, `fix/`, `docs/`, `refactor/`, `test/`.
|
||||
|
||||
### 3. Make Your Changes
|
||||
Commits follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by a
|
||||
`commit-msg` hook:
|
||||
|
||||
- Write clean, readable code that follows our coding standards (see below)
|
||||
- Add tests for new functionality
|
||||
- Update documentation as needed
|
||||
- Make sure your changes don't break existing functionality
|
||||
|
||||
### 4. Commit Your Changes
|
||||
|
||||
We follow conventional commit standards. Format your commit messages as:
|
||||
|
||||
```
|
||||
type(scope): description
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
**Types:**
|
||||
- `feat`: A new feature
|
||||
- `fix`: A bug fix
|
||||
- `docs`: Documentation only changes
|
||||
- `style`: Changes that do not affect the meaning of the code
|
||||
- `refactor`: A code change that neither fixes a bug nor adds a feature
|
||||
- `test`: Adding missing tests or correcting existing tests
|
||||
- `chore`: Changes to the build process or auxiliary tools
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
git commit -m "feat(api): add new dialectic endpoint for user insights"
|
||||
git commit -m "fix(db): resolve connection pool timeout issue"
|
||||
git commit -m "docs(readme): update installation instructions"
|
||||
```
|
||||
|
||||
### 5. Submit a Pull Request
|
||||
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`.
|
||||
|
||||
1. Push your branch to your fork:
|
||||
```bash
|
||||
git push origin your-branch-name
|
||||
```
|
||||
### Pre-commit hooks
|
||||
|
||||
2. Create a pull request on GitHub from your branch to the `main` branch
|
||||
Install them. CI runs the same checks, and it is much faster to find out locally.
|
||||
|
||||
3. Fill out the pull request template with:
|
||||
- A clear description of what changes you've made
|
||||
- The motivation for the changes
|
||||
- Any relevant issue numbers (use "Closes #123" to auto-close issues)
|
||||
- Screenshots or examples if applicable
|
||||
```bash
|
||||
uv run pre-commit install \
|
||||
--hook-type pre-commit \
|
||||
--hook-type commit-msg \
|
||||
--hook-type pre-push
|
||||
```
|
||||
|
||||
## Coding Standards
|
||||
At **commit** time: ruff lint and format, biome for TypeScript, basedpyright, bandit,
|
||||
markdownlint, and file hygiene. At **push** time: pytest, the alembic migration tests, and
|
||||
the SDK builds.
|
||||
|
||||
### Python Code Style
|
||||
That split matters — **a clean commit is not a clean push.** The test suite only runs at
|
||||
`pre-push`, so the first time you see test failures may be well after you thought you were
|
||||
done.
|
||||
|
||||
- Follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guidelines
|
||||
- Use [Black](https://black.readthedocs.io/) for code formatting (we may add this to CI in the future)
|
||||
- Use type hints where possible
|
||||
- Write docstrings for functions and classes using Google style docstrings
|
||||
Run them by hand at any time:
|
||||
|
||||
### Code Organization
|
||||
```bash
|
||||
uv run pre-commit run --all-files
|
||||
uv run pre-commit run ruff --all-files
|
||||
```
|
||||
|
||||
- Keep functions focused and single-purpose
|
||||
- Use meaningful variable and function names
|
||||
- Add comments for complex logic
|
||||
- Follow existing patterns in the codebase
|
||||
Or the individual tools:
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
uv run ruff check src/
|
||||
uv run ruff format src/
|
||||
uv run basedpyright
|
||||
```
|
||||
|
||||
- Write unit tests for new functionality
|
||||
- Ensure existing tests pass before submitting
|
||||
- Use descriptive test names that explain what is being tested
|
||||
- Mock external dependencies appropriately
|
||||
### Tests
|
||||
|
||||
Write tests for new functionality, in the directory under `tests/` that mirrors the code you
|
||||
changed. Which layer you need depends on what you touched:
|
||||
|
||||
| What you changed | What to run |
|
||||
| --- | --- |
|
||||
| Anything in `src/` | Unit tests in the matching `tests/` tree — `uv run pytest tests/...` |
|
||||
| Deriver, dialectic, dreamer, or the LLM path | Unit tests, and consider `tests/live_llm/` (gated behind `--live-llm`) |
|
||||
| Queue behavior, config hierarchy, multi-turn flows, SDK contracts | `uv run python -m tests.unified.run` |
|
||||
| A `/v3` endpoint or deriver queue behavior | Actually run the stack and exercise it — not just pytest |
|
||||
| A migration | `uv run python scripts/run_alembic_tests.py`; every revision needs a test file |
|
||||
|
||||
The TypeScript SDK tests need a running server with a database and Redis, which pytest
|
||||
orchestrates. Run them with `uv run pytest tests/ -k typescript` from the repo root —
|
||||
`bun test` on its own will fail. To type-check the SDK alone:
|
||||
`cd sdks/typescript && bun run tsc --noEmit`.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update relevant documentation for new features
|
||||
- Include examples in docstrings where helpful
|
||||
- Keep README and other docs up to date with changes
|
||||
Update docs in the same PR when you change a public surface: `/v3` endpoints, SDK exports,
|
||||
or anything in `config.toml` / settings. Docs live in `docs/v3/`, and new pages need an entry
|
||||
in `docs/docs.json` or they will not appear in the nav.
|
||||
|
||||
## Review Process
|
||||
## Opening the pull request
|
||||
|
||||
1. **Automated checks** - Your PR will run through automated checks including tests and linting
|
||||
2. **Project maintainer review** - A project maintainer will review your code for:
|
||||
- Code quality and adherence to standards
|
||||
- Functionality and correctness
|
||||
- Test coverage
|
||||
- Documentation completeness
|
||||
3. **Discussion and iteration** - You may be asked to make changes or clarifications
|
||||
4. **Approval and merge** - Once approved, your PR will be merged into `main`
|
||||
### Leave "Allow edits by maintainers" checked
|
||||
|
||||
## Types of Contributions
|
||||
This is the single most useful thing you can do to get your PR merged quickly.
|
||||
|
||||
We welcome various types of contributions:
|
||||
Most contributor PRs arrive nearly right, needing a rename, a missing test, or a lint fix.
|
||||
If we can push that commit ourselves, it merges the same day. If we cannot, it becomes a
|
||||
review comment, and then we wait — sometimes for weeks — for a round trip on a two-line
|
||||
change.
|
||||
|
||||
- **Bug fixes** - Help us squash bugs and improve stability
|
||||
- **New features** - Add functionality that benefits the community
|
||||
- **Documentation** - Improve or expand our documentation
|
||||
- **Tests** - Increase test coverage and reliability
|
||||
- **Performance improvements** - Help make Honcho faster and more efficient
|
||||
- **Examples and tutorials** - Help other developers use Honcho
|
||||
GitHub checks the box by default when you fork. Leave it checked.
|
||||
|
||||
## Issue Reporting
|
||||
One caveat worth knowing: **the option does not exist on forks owned by an organization.**
|
||||
If you have the choice, fork from your personal account.
|
||||
|
||||
When reporting bugs or requesting features:
|
||||
### Fill out the template
|
||||
|
||||
1. Check if the issue already exists
|
||||
2. Use the appropriate issue template
|
||||
3. Provide clear reproduction steps for bugs
|
||||
4. Include relevant environment information
|
||||
5. Be specific about expected vs actual behavior
|
||||
`.github/pull_request_template.md` asks for a description, proofs, and the issue checkbox.
|
||||
|
||||
## Questions and Support
|
||||
"Proofs" means evidence the change works: the command you ran and its result, a log snippet,
|
||||
a screenshot, the failing case before and after. This is the section that most determines
|
||||
how fast your PR gets reviewed. Do not add sections to the template.
|
||||
|
||||
- **General questions** - Join our [Discord](http://discord.gg/honcho)
|
||||
- **Bug reports** - Use GitHub issues
|
||||
- **Feature requests** - Use GitHub issues with the feature request template
|
||||
- **Security issues** - Please email us privately rather than opening a public issue
|
||||
Link the issue so the gate can see it: `Fixes #123` in the description, or the
|
||||
**Development** section of the sidebar. The gate reads GitHub's own resolved issue links, so
|
||||
either route works — but a bare `#123` mention is only a reference and does not count.
|
||||
|
||||
### Review
|
||||
|
||||
1. Automated checks run — tests, linting, static analysis, and the issue gate.
|
||||
2. A maintainer reviews for correctness, test coverage, and fit with the surrounding code.
|
||||
`.github/CODEOWNERS` routes the request to whoever owns the area you touched.
|
||||
3. You may be asked for changes. Or we may just push them, if you left edits enabled.
|
||||
4. Once approved, we merge to `main`.
|
||||
|
||||
If a PR goes quiet, nudge us in [Discord](https://discord.gg/honcho).
|
||||
|
||||
## Reporting bugs and requesting features
|
||||
|
||||
Use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). There is
|
||||
one per kind of report, and picking the right one is most of what gets an issue triaged
|
||||
quickly:
|
||||
|
||||
- **Bug report** — something is broken or behaves incorrectly
|
||||
- **Memory / recall quality** — the deriver or dialectic returns poor, wrong, or missing context
|
||||
- **Feature request** — a new capability or API surface
|
||||
- **Integration request** — plugins, framework integrations, app-store listings
|
||||
- **Documentation issue** — anything wrong or missing in the docs
|
||||
- **General questions** — not an issue at all; ask in [Discord](https://discord.gg/honcho)
|
||||
|
||||
Before opening one, search existing issues, including closed ones.
|
||||
|
||||
A good bug report has the Honcho version or commit, whether you are self-hosted or on
|
||||
`api.honcho.dev`, the steps to reproduce, and what you expected instead. If it involves the
|
||||
deriver, logs from the worker process are usually the thing we ask for first.
|
||||
|
||||
**Redact before you post.** Issues are public, and Honcho stores conversational data — strip
|
||||
API keys, JWTs, and production user content out of any log or payload you attach.
|
||||
|
||||
## Security
|
||||
|
||||
Do not open a public issue for a suspected vulnerability. Report it privately through
|
||||
[GitHub Private Vulnerability Reporting](https://github.com/plastic-labs/honcho/security/advisories/new),
|
||||
which is the preferred channel, or by email. See [SECURITY.md](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md) for what
|
||||
to include, and note that Honcho does not operate a bug bounty.
|
||||
|
||||
## License
|
||||
|
||||
By contributing to Honcho, you agree that your contributions will be licensed under the same [AGPL-3.0 License](./license) that covers the project.
|
||||
By contributing to Honcho, you agree that your contributions will be licensed under the same
|
||||
[AGPL-3.0 License](./license) that covers the project.
|
||||
|
||||
Thank you for helping make Honcho better! 🫡
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ icon: 'computer'
|
|||
|
||||
This guide helps you set up a local environment to run Honcho for development, testing, or self-hosting.
|
||||
|
||||
**Just want a running instance?** `uv tool install honcho-cli` only installs the `honcho` command. Then run [`honcho start --setup`](/v3/documentation/reference/cli#local-stack) (Docker + an LLM provider key) — that pulls a published image and starts API, deriver, Postgres, and Redis. The rest of this page is for building from source, contributing, or deploying without the CLI.
|
||||
|
||||
## Overview
|
||||
|
||||
By the end of this guide, you'll have:
|
||||
|
|
@ -22,7 +24,7 @@ Before you begin, ensure you have the following installed:
|
|||
### Required Software
|
||||
- **uv** - Python package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh` or `brew install uv`
|
||||
- **Git** - [Download from git-scm.com](https://git-scm.com/downloads)
|
||||
- **Docker** (required for Docker setup, not needed for manual setup) - [Download from docker.com](https://www.docker.com/products/docker-desktop/)
|
||||
- **Docker** - required for the CLI local stack and the compose-from-source path; not needed for a fully manual setup. [Download from docker.com](https://www.docker.com/products/docker-desktop/)
|
||||
|
||||
### Database Options
|
||||
You'll need a PostgreSQL database with the pgvector extension. Choose one:
|
||||
|
|
@ -59,13 +61,22 @@ DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
|||
For recommended model tiers per feature, using multiple providers, or direct vendor API keys, see the [Configuration Guide](./configuration#llm-configuration).
|
||||
</Info>
|
||||
|
||||
<Info>
|
||||
**Community quick-start**: [elkimek/honcho-self-hosted](https://github.com/elkimek/honcho-self-hosted) provides a one-command installer with pre-configured model tiers, interactive provider setup, and Hermes Agent integration.
|
||||
</Info>
|
||||
## Personal local stack (CLI)
|
||||
|
||||
## Docker Setup (Recommended)
|
||||
Recommended if you want Honcho running locally without cloning this repo or building an image. Install the CLI, then run the setup wizard:
|
||||
|
||||
Docker Compose handles the database, Redis, and Honcho server. The compose file **builds the image from source** (there is no pre-built image on Docker Hub). This requires Docker with BuildKit enabled — see [Troubleshooting](./troubleshooting#docker-build-fails-with-permission-errors) if the build fails.
|
||||
```bash
|
||||
uv tool install honcho-cli
|
||||
honcho start --setup basic # prompts for LLM provider + key, then starts Docker
|
||||
```
|
||||
|
||||
`honcho start` pulls `ghcr.io/plastic-labs/honcho:latest`, pins that digest, and starts API + deriver + Postgres + Redis. Stack files live under `~/.honcho/profiles/local/`. It does **not** rewrite `environmentUrl` in `~/.honcho/config.json` (that file is shared with plugins). Talk to the stack with `HONCHO_BASE_URL=http://127.0.0.1:8000`, or run `honcho init --base-url http://127.0.0.1:8000` to persist local as the CLI default.
|
||||
|
||||
See the [CLI reference](/v3/documentation/reference/cli#local-stack) for `--setup`, profiles, `--image`, ports, `honcho status` / `stop`, and pointing the CLI at local.
|
||||
|
||||
## From source (Docker Compose)
|
||||
|
||||
Docker Compose in this repo handles the database, Redis, and Honcho server. The compose file **builds the image from source** so you can develop against local code. A pre-built image is published at `ghcr.io/plastic-labs/honcho:latest` (what `honcho start` uses); it is not on Docker Hub. Building from source requires Docker with BuildKit enabled — see [Troubleshooting](./troubleshooting#docker-build-fails-with-permission-errors) if the build fails.
|
||||
|
||||
The compose file is production-oriented by default (ports bound to `127.0.0.1`, restart policies, caching enabled). For development, uncomment the source mounts and monitoring services inside the file.
|
||||
|
||||
|
|
@ -321,6 +332,7 @@ const client = new Honcho({
|
|||
### Next Steps
|
||||
|
||||
- **Configure Honcho**: Visit the [Configuration Guide](./configuration) for model tiers, provider options, and tuning
|
||||
- **Use the CLI**: install with `uv tool install honcho-cli`, then [`honcho start --setup`](/v3/documentation/reference/cli#local-stack) for a local stack; inspect with `honcho workspace inspect` / `honcho doctor`
|
||||
- **Explore the API**: Check out the [API Reference](../api-reference/introduction)
|
||||
- **Try the SDKs**: See our [guides](../guides) for examples
|
||||
- **Join the community**: [Discord](https://discord.gg/honcho)
|
||||
|
|
@ -334,11 +346,12 @@ Running into issues? See the [Troubleshooting Guide](./troubleshooting) for deta
|
|||
- Deriver not processing messages
|
||||
- Database connection and migration issues
|
||||
- Docker and Redis problems
|
||||
- CLI local stack (`honcho start`) — missing LLM key, health timeout, still talking to api.honcho.dev
|
||||
|
||||
**Quick checks:**
|
||||
- Verify the server is running: `curl http://localhost:8000/health`
|
||||
- Check logs: `docker compose logs api` (Docker) or check terminal output (manual setup)
|
||||
- Ensure migrations ran: `uv run alembic upgrade head`
|
||||
- Check logs: `docker compose logs api` (from-source Docker), `docker compose -p honcho-local logs` (`honcho start`), or terminal output (manual setup)
|
||||
- Ensure migrations ran: `uv run alembic upgrade head` (from-source only; `honcho start` runs them in the image entrypoint)
|
||||
|
||||
## Production Considerations
|
||||
|
||||
|
|
|
|||
|
|
@ -316,11 +316,37 @@ docker compose build --no-cache
|
|||
docker compose up -d
|
||||
```
|
||||
|
||||
## CLI local stack (`honcho start`)
|
||||
|
||||
The CLI local stack lives under `~/.honcho/profiles/` (default profile `local`) and uses the published image `ghcr.io/plastic-labs/honcho:latest`. Logs: `docker compose -p honcho-local logs`. Full flags: [CLI reference](/v3/documentation/reference/cli#local-stack).
|
||||
|
||||
### `MISSING_LLM_KEY`
|
||||
|
||||
**Cause:** No provider key in the environment or the profile `.env`.
|
||||
|
||||
**Fix:** Export `LLM_OPENAI_API_KEY`, `LLM_ANTHROPIC_API_KEY`, or `LLM_GEMINI_API_KEY`, or run `honcho start --setup basic` in a TTY (not with `--json`).
|
||||
|
||||
### Timed out waiting for `/health`
|
||||
|
||||
**Cause:** The API container started but did not become ready within `--timeout` (default 180s).
|
||||
|
||||
**Fix:** Check `docker compose -p honcho-local logs api` and `... logs deriver`. Increase `--timeout`. Confirm Docker is running. A first-time GHCR pull happens *before* this wait (during image pin) — if that step hung, look at Docker pull logs instead.
|
||||
|
||||
### CLI still talks to `api.honcho.dev`
|
||||
|
||||
**Cause:** `honcho start` does not rewrite `environmentUrl` in `~/.honcho/config.json`.
|
||||
|
||||
**Fix:** Prefix commands with `HONCHO_BASE_URL=http://127.0.0.1:8000`, or run `honcho init --base-url http://127.0.0.1:8000` to persist local as the default. `honcho status` prints the one-shot hint.
|
||||
|
||||
### Port already in use
|
||||
|
||||
The CLI remaps 8000/5432/6379 automatically unless you pinned them with `--api-port` / `--db-port` / `--redis-port`. Pass those flags if you need a specific host port.
|
||||
|
||||
## Getting Help
|
||||
|
||||
If your issue isn't covered here:
|
||||
|
||||
- **Check the logs** — most issues are diagnosed from server or deriver logs
|
||||
- **Check the logs** — most issues are diagnosed from server or deriver logs (`docker compose logs` for from-source compose; `docker compose -p honcho-local logs` for `honcho start`)
|
||||
- **GitHub Issues** — [Report bugs](https://github.com/plastic-labs/honcho/issues)
|
||||
- **Discord** — [Join our community](https://discord.gg/plasticlabs)
|
||||
- **Configuration** — See the [Configuration Guide](./configuration) for all available settings
|
||||
|
|
|
|||
|
|
@ -94,6 +94,9 @@ Welcome to Honcho. We're excited to have you at the frontier of AI with us 🫡.
|
|||
<Card title="Quickstart" icon="rocket" href="/v3/documentation/introduction/quickstart">
|
||||
Build your first stateful agent in minutes
|
||||
</Card>
|
||||
<Card title="CLI" icon="terminal" href="/v3/documentation/reference/cli">
|
||||
Inspect a deployment, or `honcho start --setup` a local stack
|
||||
</Card>
|
||||
<Card title="Architecture" icon="sitemap" href="/v3/documentation/core-concepts/architecture">
|
||||
Deep dive into how Honcho's primitives fit together
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ Let's get started with Honcho. In this quickstart, you will:
|
|||
- Query the reasoning Honcho produces to get synthesized insights about the user
|
||||
|
||||
<Note>
|
||||
Running the code below requires an API key. Create and account and get your API key at [app.honcho.dev](https://app.honcho.dev) under "API KEYS".
|
||||
Running the code below requires an API key. Create an account and get your API key at [app.honcho.dev](https://app.honcho.dev) under "API KEYS".
|
||||
|
||||
Every new tenant gets \$100.00 in free credits on sign up. The code below costs ~\$0.04 to run, so don't worry--still plenty of free credits for iterating.
|
||||
|
||||
To run against a local stack instead, install the CLI (`uv tool install honcho-cli`) and then run `honcho start --setup` (Docker + an LLM provider key). See the [CLI reference](/v3/documentation/reference/cli#local-stack).
|
||||
</Note>
|
||||
|
||||
#### 1. Install the SDK
|
||||
|
|
|
|||
|
|
@ -68,16 +68,19 @@ claude mcp add honcho \
|
|||
|
||||
## CLI
|
||||
|
||||
Inspect and debug a running Honcho deployment from your terminal. The honcho CLI wraps the Python SDK with agent-friendly defaults — JSON output, structured errors, and commands for every primitive (workspaces, peers, sessions, messages, conclusions).
|
||||
Inspect and debug a running Honcho deployment from your terminal, or run a personal local stack. The honcho CLI wraps the Python SDK with agent-friendly defaults — JSON output, structured errors, and commands for every primitive (workspaces, peers, sessions, messages, conclusions).
|
||||
|
||||
**Get started:**
|
||||
|
||||
```bash
|
||||
uv tool install honcho-cli
|
||||
honcho init # configure apiKey + environmentUrl
|
||||
honcho doctor # verify connectivity
|
||||
honcho init # Honcho API key / browser login (talk *to* a server)
|
||||
honcho start --setup basic # local stack: LLM provider key + Docker
|
||||
honcho doctor # verify connectivity
|
||||
```
|
||||
|
||||
`honcho start --setup` pulls the published GHCR image — no clone required. It does not rewrite `environmentUrl` in the shared config file; prefix commands with `HONCHO_BASE_URL=http://127.0.0.1:8000` to talk to local.
|
||||
|
||||
The CLI also ships an agent skill. Install it with `npx skills add plastic-labs/honcho` and pick `honcho-cli` from the list.
|
||||
|
||||
See the [full CLI reference](/v3/documentation/reference/cli) for all commands, flags, and environment variables.
|
||||
|
|
@ -147,7 +150,7 @@ Invoke with `/honcho-integration` in your coding agent.
|
|||
|
||||
#### honcho-cli
|
||||
|
||||
**For inspection & debugging.** Teaches your coding agent the right commands and flags for the [honcho CLI](#cli) — peer memory, session context, queue status, dialectic quality.
|
||||
**For inspection & debugging, and for running a local stack.** Teaches your coding agent the right commands and flags for the [honcho CLI](#cli) — peer memory, session context, queue status, dialectic quality, `honcho start` / `status` / `stop`.
|
||||
|
||||
Invoke implicitly when you ask your agent to inspect a Honcho deployment.
|
||||
|
||||
|
|
@ -170,7 +173,7 @@ I want to start building with Honcho - an open source memory library for buildin
|
|||
- Core repo: https://github.com/plastic-labs/honcho
|
||||
- Python SDK: https://github.com/plastic-labs/honcho-python
|
||||
- TypeScript SDK: https://github.com/plastic-labs/honcho-node
|
||||
- CLI (inspect & debug a deployment): https://github.com/plastic-labs/honcho/tree/main/honcho-cli
|
||||
- CLI (inspect, debug, or `honcho start` a local stack): https://github.com/plastic-labs/honcho/tree/main/honcho-cli
|
||||
- Discord bot starter: https://github.com/plastic-labs/discord-python-starter
|
||||
- Telegram bot example: https://github.com/plastic-labs/telegram-python-starter
|
||||
|
||||
|
|
|
|||
|
|
@ -18,14 +18,45 @@ uvx honcho-cli
|
|||
```
|
||||
</CodeGroup>
|
||||
|
||||
This only installs the `honcho` command. It does not start a server. Use `honcho start --setup` (Docker + an LLM provider key) when you want a local stack.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json
|
||||
honcho doctor # verify your config + connectivity
|
||||
honcho # show banner + command list
|
||||
honcho init # Honcho API key or browser login + server URL (talk *to* Honcho)
|
||||
honcho start --setup basic # local stack: LLM provider key + Docker (not set by init)
|
||||
honcho doctor # verify your config + connectivity
|
||||
honcho # show banner + command list
|
||||
```
|
||||
|
||||
`honcho init` authenticates the CLI against a Honcho server. It does **not** configure the LLM key a local stack needs — that is `honcho start --setup`.
|
||||
|
||||
## Local stack
|
||||
|
||||
`honcho start --setup basic` is the fastest way to run Honcho on your machine. It does **not** require cloning the Honcho repo. The wizard prompts for an LLM provider and API key, writes them into the profile `.env`, pulls `ghcr.io/plastic-labs/honcho:latest`, **pins that digest**, and starts API + deriver + Postgres + Redis via Docker.
|
||||
|
||||
Default profile is `local` (`--profile` / `HONCHO_PROFILE`). First start copies the image `config.toml.example` into the profile directory; later starts leave that file alone so your edits persist — including when you re-pin the image. Delete `config.toml` yourself if you want a fresh copy from a new image. Pass `--image` to pin a different tag or digest. Ports bind to `127.0.0.1`; if 8000/5432/6379 are taken, the CLI remaps them (or pass `--api-port` / `--db-port` / `--redis-port`). Auth is off (`AUTH_USE_AUTH=false`).
|
||||
|
||||
Pass `--setup basic` or `--setup advanced` for an interactive wizard that writes curated LLM/feature overrides into the profile `.env` (environment variables win over `config.toml`). This is TTY-only. `basic` covers provider and chat model; `advanced` also covers embeddings, deriver/dialectic models, dreams, and deriver flush. Re-running `--setup` while the stack is up recreates the API and deriver containers.
|
||||
|
||||
This does **not** change `environmentUrl` in the shared config file. To talk to the local stack:
|
||||
|
||||
```bash
|
||||
HONCHO_BASE_URL=http://127.0.0.1:8000 honcho workspace list
|
||||
honcho init --base-url http://127.0.0.1:8000 # persist local as the CLI default
|
||||
```
|
||||
|
||||
```bash
|
||||
honcho start --setup basic
|
||||
honcho start --setup advanced
|
||||
LLM_OPENAI_API_KEY=sk-... honcho start # skip the wizard if the key is already in the env
|
||||
honcho status
|
||||
honcho stop # keep data
|
||||
honcho stop --wipe # also delete volumes
|
||||
```
|
||||
|
||||
To **develop the server** (live reload, from-source image), see [Local Environment Setup](/v3/contributing/self-hosting).
|
||||
|
||||
## Configuration
|
||||
|
||||
The CLI resolves config in this order: **flag → env var → config file → default**.
|
||||
|
|
@ -38,12 +69,17 @@ The CLI resolves config in this order: **flag → env var → config file → de
|
|||
| Peer | — | `HONCHO_PEER_ID` | `-p` / `--peer` | No |
|
||||
| Session | — | `HONCHO_SESSION_ID` | `-s` / `--session` | No |
|
||||
| JSON output | — | `HONCHO_JSON` | `--json` | No |
|
||||
| Update nag | — | `HONCHO_NO_UPDATE_CHECK` | — | No |
|
||||
| Local stack | — | `HONCHO_PROFILE` | `--profile` | No |
|
||||
|
||||
### Persisted config
|
||||
|
||||
The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns only
|
||||
The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns
|
||||
`apiKey` and `environmentUrl` at the top level — everything else (`hosts`,
|
||||
`sessions`, etc.) is written by other tools and left untouched on save.
|
||||
On managed servers that advertise the device grant in OAuth metadata,
|
||||
`honcho init` can log you in via the browser; tokens auto-refresh
|
||||
and are stored under `oauth` without deleting a shared `apiKey`.
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -89,6 +125,8 @@ Every command adapts its output to the context:
|
|||
- **Piped or redirected** — JSON automatically (detected via `isatty`).
|
||||
- **`--json` flag / `HONCHO_JSON=1`** — force JSON regardless of terminal.
|
||||
|
||||
Interactive sessions may print a one-line upgrade hint on stderr at most once a day when a newer `honcho-cli` is on PyPI (`uv tool upgrade honcho-cli`). JSON/piped output skips it; set `HONCHO_NO_UPDATE_CHECK=1` to disable it.
|
||||
|
||||
Collection commands emit JSON arrays; single-resource commands emit JSON objects. Errors are always structured:
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -67,10 +67,6 @@ Or manually create/edit the config file (checked in order: `$HERMES_HOME/honcho.
|
|||
|
||||
For the full list of config fields (`recallMode`, `writeFrequency`, `sessionStrategy`, `dialecticReasoningLevel`, etc.), see the [Hermes memory provider docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory-providers#honcho).
|
||||
|
||||
<Info>
|
||||
**Community quick-start**: [elkimek/honcho-self-hosted](https://github.com/elkimek/honcho-self-hosted) provides a one-command installer with pre-configured model tiers and Hermes Agent integration.
|
||||
</Info>
|
||||
|
||||
## Verifying the integration
|
||||
|
||||
### 1. Check status
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"url": "https://honcho.dev/",
|
||||
"email": "hello@plasticlabs.ai"
|
||||
},
|
||||
"version": "3.0.12"
|
||||
"version": "3.1.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,13 +7,27 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.1.4] - 2026-08-26
|
||||
|
||||
### Added
|
||||
|
||||
- `honcho session view` — session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session
|
||||
- A TTY notice when a newer `honcho-cli` is on PyPI (`uv tool upgrade honcho-cli`). Skipped in JSON mode; disable with `HONCHO_NO_UPDATE_CHECK`
|
||||
|
||||
### Fixed
|
||||
|
||||
- `honcho message list --last N` no longer stops at the first page of 50 — it walks pages to fill the requested window
|
||||
- `--setup` for openai-compatible writes `EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL` into the profile `.env` alongside `LLM_OPENAI_BASE_URL` (#1068)
|
||||
- `--setup` API key prompts echo `*` per character so a paste is visibly received instead of a blank getpass field
|
||||
|
||||
## [0.1.3] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- `honcho start`, `honcho stop`, and `honcho status` — run a personal Honcho stack in Docker (API, deriver, Postgres, Redis). Profiles live under `~/.honcho/profiles/`. First start pins `ghcr.io/plastic-labs/honcho:latest` by digest and copies the image `config.toml`. Optional `--setup basic` / `--setup advanced` wizard writes LLM overrides to `.env` (#1029)
|
||||
- `honcho session view` — session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session (#1006)
|
||||
|
||||
### Fixed
|
||||
|
||||
- `honcho message list --last N` no longer stops at the first page of 50 — it walks pages to fill the requested window (#1006)
|
||||
|
||||
## [0.1.2] - 2026-07-20
|
||||
|
||||
|
|
|
|||
|
|
@ -22,22 +22,50 @@ uv tool install honcho-cli
|
|||
## Quick Start
|
||||
|
||||
```bash
|
||||
honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json
|
||||
honcho doctor # verify your config + connectivity
|
||||
honcho # show banner + command list
|
||||
honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json
|
||||
honcho start --setup basic # local stack: LLM provider key + Docker
|
||||
honcho doctor # verify config + connectivity
|
||||
honcho # show banner + command list
|
||||
```
|
||||
|
||||
`honcho init` reads `apiKey` and `environmentUrl` from the top-level of `~/.honcho/config.json` (the same file other Honcho tools — plugins, host integrations — share). If both are present, it confirms them with you; if either is missing (or you decline), it prompts for the missing value(s) and writes them back. Host-specific entries under `hosts` are left untouched.
|
||||
`honcho init` writes `apiKey` and `environmentUrl` to the top-level of `~/.honcho/config.json` (the same file other Honcho tools — plugins, host integrations — share) so the CLI can call a Honcho server. If both are present, it confirms them with you; if either is missing (or you decline), it prompts and writes them back. Host-specific entries under `hosts` are left untouched. It does **not** set the LLM provider key the local deriver needs — that is `honcho start --setup` (or `LLM_*_API_KEY` in the environment).
|
||||
|
||||
Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars — not persisted as CLI defaults.
|
||||
|
||||
### Local stack
|
||||
|
||||
`honcho start --setup basic` runs a personal Honcho server on your machine (API, deriver, Postgres, Redis) via Docker. The wizard writes the LLM provider key into the profile `.env` — `honcho init` cannot do this; its `apiKey` is for calling a Honcho server, not for deriver/dialectic inference. You can also pass `LLM_OPENAI_API_KEY`, `LLM_ANTHROPIC_API_KEY`, or `LLM_GEMINI_API_KEY` in the environment and skip `--setup`. Stack files live under `~/.honcho/profiles/local/` and are not committed to a project.
|
||||
|
||||
On first start, the CLI pulls `ghcr.io/plastic-labs/honcho:latest` and **pins that digest** in `profile.json`, then copies the image's `config.toml.example` to `config.toml` in the same directory. `honcho start` never overwrites `config.toml` after that — including when you re-pin the image. Delete the file yourself if you want a fresh copy from a new image.
|
||||
|
||||
Pass `--setup basic` or `--setup advanced` for an interactive wizard that writes curated LLM/feature overrides into the profile `.env` (env wins over `config.toml`). TTY only; re-runnable. `basic` asks provider + chat model; `advanced` also covers embeddings, deriver/dialectic models, dreams, and snappy deriver flush. Everything else stays in `config.toml`.
|
||||
|
||||
`honcho start` does **not** change `environmentUrl` in `~/.honcho/config.json` (that file is shared with plugins). To talk to the local stack for one command:
|
||||
|
||||
```bash
|
||||
HONCHO_BASE_URL=http://127.0.0.1:8000 honcho workspace list
|
||||
```
|
||||
|
||||
To make local the default, run `honcho init --base-url http://127.0.0.1:8000`.
|
||||
|
||||
```bash
|
||||
honcho start --setup basic
|
||||
honcho start --setup advanced
|
||||
honcho status
|
||||
honcho stop # keep data
|
||||
honcho stop --wipe # also delete volumes
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Onboarding
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `honcho init` | Confirm/set `apiKey` + `environmentUrl` in `~/.honcho/config.json` |
|
||||
| `honcho init` | Confirm/set `apiKey` + `environmentUrl` in `~/.honcho/config.json`. |
|
||||
| `honcho start` | Start a local Honcho stack (API, deriver, Postgres, Redis). Requires Docker and a cloud LLM key. `--setup basic` / `--setup advanced` runs an interactive config wizard (TTY only). Does not change `environmentUrl`. |
|
||||
| `honcho stop` | Stop the local stack. `--wipe` also deletes volumes. |
|
||||
| `honcho status` | Show every local stack (or `--profile` for one). |
|
||||
| `honcho doctor` | Health check: config, connectivity, workspace, peer, queue |
|
||||
|
||||
### Workspaces
|
||||
|
|
@ -157,6 +185,9 @@ Precedence (highest first): **flag → env var → config file → default**.
|
|||
| `HONCHO_PEER_ID` | `-p` / `--peer` | Peer scope |
|
||||
| `HONCHO_SESSION_ID` | `-s` / `--session` | Session scope |
|
||||
| `HONCHO_JSON` | `--json` | Force JSON output (`1` / `true`) |
|
||||
| `HONCHO_NO_UPDATE_CHECK` | — | Disable the once-a-day upgrade notice (`1` / `true`) |
|
||||
| `HONCHO_PROFILE` | `--profile` (start/stop/status) | Local stack profile (default: `local`) |
|
||||
| `LLM_OPENAI_API_KEY` | — | Provider key for `honcho start` (also `LLM_ANTHROPIC_API_KEY`, `LLM_GEMINI_API_KEY`) |
|
||||
|
||||
```bash
|
||||
# Per-command flags
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "honcho-cli"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
description = "A terminal for Honcho — memory that reasons."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
|
@ -38,6 +38,10 @@ build-backend = "hatchling.build"
|
|||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/honcho_cli"]
|
||||
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"src/honcho_cli/local/templates/docker-compose.yml" = "honcho_cli/local/templates/docker-compose.yml"
|
||||
"src/honcho_cli/local/templates/init.sql" = "honcho_cli/local/templates/init.sql"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""Honcho CLI — a terminal for Honcho."""
|
||||
|
||||
__version__ = "0.1.2"
|
||||
__version__ = "0.1.4"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from typer.core import TyperGroup
|
|||
from honcho_cli import __version__
|
||||
from honcho_cli.branding import BANNER, BRAND
|
||||
from honcho_cli.output import use_json
|
||||
from honcho_cli.update_check import maybe_print_update_nag
|
||||
|
||||
|
||||
# Theme Typer's rich help renderer. Module-level side effect limited to
|
||||
|
|
@ -59,15 +60,21 @@ def _welcome_panel(title: str, rows: list[tuple[str, str]]) -> Panel:
|
|||
|
||||
|
||||
def print_welcome(console: Console) -> None:
|
||||
"""Render the curated 3-panel welcome (banner + getting started / memory / commands)."""
|
||||
"""Render the curated welcome (banner + getting started / local stack / commands / memory)."""
|
||||
if use_json():
|
||||
return
|
||||
console.print(f"[bold {BRAND}]{BANNER}[/bold {BRAND}]")
|
||||
console.print(f" [dim]v{__version__}[/dim]\n", highlight=False)
|
||||
|
||||
start_rows = [
|
||||
("honcho init", "configure API key and server URL"),
|
||||
("honcho doctor", "verify connection and workspace health"),
|
||||
("honcho init", "configure API key and server URL"),
|
||||
("honcho start [--setup basic]", "run a local Honcho stack (Docker)"),
|
||||
("honcho doctor", "verify connection and workspace health"),
|
||||
]
|
||||
stack_rows = [
|
||||
("honcho start / status / stop", "lifecycle for the local Docker stack"),
|
||||
("honcho start --setup basic", "interactive LLM + feature wizard"),
|
||||
("HONCHO_BASE_URL=http://127.0.0.1:8000", "prefix any command — CLI stays on api.honcho.dev until you set this"),
|
||||
]
|
||||
cmd_rows = [
|
||||
("[dim]pattern[/dim]", r"[dim]honcho <command> \[args] \[-w workspace] \[-p peer] \[-s session][/dim]"),
|
||||
|
|
@ -84,14 +91,15 @@ def print_welcome(console: Console) -> None:
|
|||
("config", "inspect current configuration"),
|
||||
]
|
||||
memory_rows = [
|
||||
("honcho peer chat \"...\" -p <peer> -w <workspace>","query the Dialectic about a peer"),
|
||||
("honcho peer inspect -p <peer> -w <workspace>","dashboard: peer card + recent conclusions + configuration"),
|
||||
("honcho peer chat \"...\" -p <peer> -w <workspace>", "query the Dialectic about a peer"),
|
||||
("honcho peer inspect -p <peer> -w <workspace>", "dashboard: peer card + recent conclusions + configuration"),
|
||||
("honcho peer representation -p <peer> -w <workspace>", "global peer representation"),
|
||||
("honcho peer representation -p <peer> -w <workspace> -s <session>", "session-scoped peer representation"),
|
||||
("honcho peer card -p <peer> -w <workspace>", "synthesized identity: traits, preferences, instructions"),
|
||||
("honcho conclusion list -p <peer> -w <workspace>", "browse peer conclusions"),
|
||||
("honcho conclusion list -p <peer> -w <workspace>", "browse peer conclusions"),
|
||||
("honcho session view / context -s <session>", "transcript, or what an agent would see"),
|
||||
("honcho workspace queue-status", "is the deriver processing?"),
|
||||
]
|
||||
|
||||
option_rows = [
|
||||
("-w / --workspace", "scope to a workspace"),
|
||||
("-p / --peer", "scope to a peer"),
|
||||
|
|
@ -101,10 +109,12 @@ def print_welcome(console: Console) -> None:
|
|||
]
|
||||
|
||||
console.print(_welcome_panel("getting started", start_rows))
|
||||
console.print(_welcome_panel("local stack", stack_rows))
|
||||
console.print(_welcome_panel("commands", cmd_rows))
|
||||
console.print(_welcome_panel("memory", memory_rows))
|
||||
console.print(_welcome_panel("options", option_rows))
|
||||
console.print()
|
||||
maybe_print_update_nag()
|
||||
|
||||
|
||||
class HonchoTyperGroup(TyperGroup):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,400 @@
|
|||
"""Local stack lifecycle: ``honcho start``, ``honcho stop``, ``honcho status``.
|
||||
|
||||
Does not mutate ``~/.honcho/config.json``. The CLI stays pointed at whatever
|
||||
``honcho init`` configured (typically api.honcho.dev). Print the local URL
|
||||
and a one-shot ``HONCHO_BASE_URL=...`` hint instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from honcho_cli.branding import BRAND, ICON_FAIL, ICON_OK
|
||||
from honcho_cli.local import (
|
||||
DEFAULT_HEALTH_TIMEOUT,
|
||||
DEFAULT_IMAGE,
|
||||
DEFAULT_PROFILE,
|
||||
STACK_SERVICES,
|
||||
)
|
||||
from honcho_cli.local.docker import (
|
||||
DockerError,
|
||||
allocate_host_ports,
|
||||
compose_down,
|
||||
compose_ps,
|
||||
compose_up,
|
||||
pin_image,
|
||||
seed_config_toml,
|
||||
services_running,
|
||||
)
|
||||
from honcho_cli.local.env import has_provider_key, render_stack, settings_from_environ
|
||||
from honcho_cli.local.health import stack_healthy, wait_for_health
|
||||
from honcho_cli.local.profile import (
|
||||
LocalProfile,
|
||||
list_profile_names,
|
||||
load_profile,
|
||||
resolve_profile_name,
|
||||
save_profile,
|
||||
)
|
||||
from honcho_cli.local.setup import (
|
||||
SETUP_MODES,
|
||||
answers_drop_keys,
|
||||
answers_to_env,
|
||||
run_setup,
|
||||
)
|
||||
from honcho_cli.output import (
|
||||
fail,
|
||||
ok,
|
||||
print_error,
|
||||
print_json,
|
||||
print_result,
|
||||
set_json_mode,
|
||||
step,
|
||||
use_json,
|
||||
)
|
||||
|
||||
_console = Console(stderr=True)
|
||||
|
||||
_MISSING_LLM_KEY = (
|
||||
"Set LLM_OPENAI_API_KEY, LLM_ANTHROPIC_API_KEY, or LLM_GEMINI_API_KEY, "
|
||||
"or run honcho start --setup basic."
|
||||
)
|
||||
|
||||
|
||||
def _die(code: str, message: str, details: dict | None = None) -> None:
|
||||
print_error(code, message, details)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _validate_setup(setup: str | None) -> str | None:
|
||||
if setup is None:
|
||||
return None
|
||||
mode = setup.strip().lower()
|
||||
if mode not in SETUP_MODES:
|
||||
_die(
|
||||
"INVALID_SETUP",
|
||||
f"Unknown setup mode {setup!r}. Use --setup basic or --setup advanced.",
|
||||
{"setup": setup},
|
||||
)
|
||||
if use_json():
|
||||
_die(
|
||||
"SETUP_REQUIRES_TTY",
|
||||
"honcho start --setup is interactive. Run it in a terminal without --json.",
|
||||
{"setup": mode},
|
||||
)
|
||||
return mode
|
||||
|
||||
|
||||
def _payload(
|
||||
profile: LocalProfile, status: str, services: dict[str, str] | None = None
|
||||
) -> dict:
|
||||
return {
|
||||
"profile": profile.name,
|
||||
"status": status,
|
||||
"image": profile.image,
|
||||
"endpoints": profile.endpoints(),
|
||||
"services": services or {},
|
||||
"hint": f"HONCHO_BASE_URL={profile.base_url} honcho workspace list",
|
||||
}
|
||||
|
||||
|
||||
def _print_stack(payload: dict) -> None:
|
||||
if use_json():
|
||||
print_json(payload)
|
||||
return
|
||||
endpoints = payload["endpoints"]
|
||||
_console.print()
|
||||
table_data = {
|
||||
"API": endpoints["api"],
|
||||
"Docs": endpoints["docs"],
|
||||
"Postgres": endpoints["postgres"],
|
||||
"Redis": endpoints["redis"],
|
||||
}
|
||||
print_result(table_data)
|
||||
_console.print()
|
||||
_console.print(
|
||||
" [dim]CLI still points at your configured server (typically api.honcho.dev).[/dim]"
|
||||
)
|
||||
_console.print(f" [dim]To talk to this stack:[/dim] {payload['hint']}")
|
||||
_console.print()
|
||||
|
||||
|
||||
def _print_running(profile: LocalProfile) -> None:
|
||||
_print_stack(_payload(profile, "running", services_running(compose_ps(profile))))
|
||||
|
||||
|
||||
def _seed_config(profile: LocalProfile) -> None:
|
||||
if seed_config_toml(profile):
|
||||
ok("config.toml")
|
||||
|
||||
|
||||
def _inspect(profile: LocalProfile) -> tuple[dict[str, str], bool]:
|
||||
"""Compose service states and whether the API is healthy."""
|
||||
return services_running(compose_ps(profile)), stack_healthy(profile)
|
||||
|
||||
|
||||
def start(
|
||||
profile_name: str = typer.Option(
|
||||
DEFAULT_PROFILE,
|
||||
"--profile",
|
||||
envvar="HONCHO_PROFILE",
|
||||
help="Local stack profile name",
|
||||
),
|
||||
api_port: int | None = typer.Option(
|
||||
None, "--api-port", min=1, max=65535, help="Host port for the API"
|
||||
),
|
||||
db_port: int | None = typer.Option(
|
||||
None, "--db-port", min=1, max=65535, help="Host port for Postgres"
|
||||
),
|
||||
redis_port: int | None = typer.Option(
|
||||
None, "--redis-port", min=1, max=65535, help="Host port for Redis"
|
||||
),
|
||||
setup: str | None = typer.Option(
|
||||
None,
|
||||
"--setup",
|
||||
help="Interactive config wizard: basic (provider/model) or advanced "
|
||||
"(embeddings, deriver, dialectic, dreams, flush)",
|
||||
),
|
||||
image: str | None = typer.Option(
|
||||
None,
|
||||
"--image",
|
||||
help=f"Honcho image to pull and pin by digest (default: {DEFAULT_IMAGE})",
|
||||
),
|
||||
timeout: int = typer.Option(
|
||||
DEFAULT_HEALTH_TIMEOUT,
|
||||
"--timeout",
|
||||
min=1,
|
||||
help="Seconds to wait for /health after compose up",
|
||||
),
|
||||
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
||||
) -> None:
|
||||
"""Start a local Honcho stack (API, deriver, Postgres, Redis).
|
||||
|
||||
Requires Docker. Uses cloud LLM providers. Does not change the CLI's
|
||||
configured server URL — pass HONCHO_BASE_URL to talk to this stack.
|
||||
``--setup basic`` or ``--setup advanced`` runs an interactive config wizard.
|
||||
"""
|
||||
if json_output:
|
||||
set_json_mode(True)
|
||||
|
||||
setup = _validate_setup(setup)
|
||||
name = resolve_profile_name(profile_name)
|
||||
profile = load_profile(name).overlay(
|
||||
api_port=api_port,
|
||||
db_port=db_port,
|
||||
redis_port=redis_port,
|
||||
image=image,
|
||||
)
|
||||
pinned_ports = frozenset(
|
||||
name
|
||||
for name, value in (
|
||||
("api", api_port),
|
||||
("database", db_port),
|
||||
("redis", redis_port),
|
||||
)
|
||||
if value is not None
|
||||
)
|
||||
|
||||
if not use_json():
|
||||
_console.print(f"\n[bold {BRAND}]Honcho Start[/bold {BRAND}]\n")
|
||||
|
||||
try:
|
||||
already_running = stack_healthy(profile)
|
||||
if already_running and not setup:
|
||||
ok(f"Already running ({profile.base_url})")
|
||||
_print_running(profile)
|
||||
return
|
||||
|
||||
if not already_running:
|
||||
profile, remapped = allocate_host_ports(profile, pinned=pinned_ports)
|
||||
for service, (old, new) in remapped.items():
|
||||
step(f"Port {old} in use; {service} on {new}")
|
||||
|
||||
step(f"Pinning {profile.image}")
|
||||
pinned_image = pin_image(profile.image)
|
||||
profile = profile.overlay(image=pinned_image)
|
||||
ok(pinned_image)
|
||||
|
||||
extra = settings_from_environ()
|
||||
drop: tuple[str, ...] = ()
|
||||
_seed_config(profile)
|
||||
if setup:
|
||||
answers = run_setup(
|
||||
setup,
|
||||
profile.env_file(),
|
||||
config_path=profile.config_file(),
|
||||
)
|
||||
extra.update(answers_to_env(answers))
|
||||
drop = answers_drop_keys(answers)
|
||||
ok(f"Wrote overrides to {profile.env_file()}")
|
||||
_console.print(
|
||||
f" [dim]Other settings live in {profile.config_file()}[/dim]"
|
||||
)
|
||||
elif not has_provider_key(profile, extra):
|
||||
_die("MISSING_LLM_KEY", _MISSING_LLM_KEY)
|
||||
|
||||
step(f"Writing stack config to {profile.dir()}")
|
||||
save_profile(profile)
|
||||
render_stack(profile, extra=extra, drop=drop)
|
||||
ok(f"Profile '{profile.name}'")
|
||||
|
||||
step("Starting containers" if not already_running else "Recreating api + deriver")
|
||||
compose_up(
|
||||
profile,
|
||||
recreate=("api", "deriver") if already_running else (),
|
||||
)
|
||||
|
||||
step(f"Waiting for API at {profile.base_url}/health")
|
||||
if not wait_for_health(profile, timeout=float(timeout)):
|
||||
fail("Timed out waiting for /health")
|
||||
_die(
|
||||
"HEALTH_TIMEOUT",
|
||||
f"Stack started but {profile.base_url}/health did not become ready within {timeout}s. "
|
||||
f"Check `docker compose -p {profile.project_name} logs`.",
|
||||
{
|
||||
"base_url": profile.base_url,
|
||||
"timeout": timeout,
|
||||
"project": profile.project_name,
|
||||
},
|
||||
)
|
||||
|
||||
ok("Honcho is running")
|
||||
_print_running(profile)
|
||||
except DockerError as e:
|
||||
e.exit()
|
||||
|
||||
|
||||
def stop(
|
||||
profile_name: str = typer.Option(
|
||||
DEFAULT_PROFILE,
|
||||
"--profile",
|
||||
envvar="HONCHO_PROFILE",
|
||||
help="Local stack profile name",
|
||||
),
|
||||
wipe: bool = typer.Option(
|
||||
False, "--wipe", help="Also delete volumes (Postgres data)"
|
||||
),
|
||||
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
||||
) -> None:
|
||||
"""Stop the local stack started by `honcho start`. Keeps data unless --wipe."""
|
||||
if json_output:
|
||||
set_json_mode(True)
|
||||
|
||||
name = resolve_profile_name(profile_name)
|
||||
profile = load_profile(name)
|
||||
|
||||
try:
|
||||
if not profile.compose_file().exists():
|
||||
payload = _payload(profile, "stopped")
|
||||
if use_json():
|
||||
print_json(payload)
|
||||
else:
|
||||
_console.print(
|
||||
f" [dim]No local stack for profile '{profile.name}'.[/dim]"
|
||||
)
|
||||
return
|
||||
|
||||
running = bool(compose_ps(profile))
|
||||
if not running and not wipe:
|
||||
if use_json():
|
||||
print_json(_payload(profile, "stopped"))
|
||||
else:
|
||||
_console.print(
|
||||
f" [dim]Profile '{profile.name}' is already stopped.[/dim]"
|
||||
)
|
||||
return
|
||||
|
||||
compose_down(profile, wipe=wipe)
|
||||
except DockerError as e:
|
||||
e.exit()
|
||||
|
||||
state = "wiped" if wipe else "stopped"
|
||||
ok(f"Stopped profile '{profile.name}'" + (" (volumes removed)" if wipe else ""))
|
||||
if use_json():
|
||||
print_json(_payload(profile, state))
|
||||
|
||||
|
||||
def _status_one(profile: LocalProfile) -> bool:
|
||||
"""Print one profile's status. Return True when the API is healthy."""
|
||||
try:
|
||||
services, running = _inspect(profile)
|
||||
except DockerError as e:
|
||||
e.exit()
|
||||
data = _payload(profile, "running" if running else "stopped", services)
|
||||
if not use_json():
|
||||
icon = ICON_OK if running else ICON_FAIL
|
||||
_console.print(f"\n {icon} profile '{profile.name}' is {data['status']}\n")
|
||||
if services:
|
||||
for svc in STACK_SERVICES:
|
||||
detail = services.get(svc, "missing")
|
||||
_console.print(f" {svc:<10} [dim]{detail}[/dim]")
|
||||
_print_stack(data)
|
||||
return running
|
||||
|
||||
|
||||
def status(
|
||||
profile_name: str | None = typer.Option(
|
||||
None,
|
||||
"--profile",
|
||||
envvar="HONCHO_PROFILE",
|
||||
help="Limit to this profile. Omit to show every local stack.",
|
||||
),
|
||||
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
||||
) -> None:
|
||||
"""Show local stack endpoints and container health.
|
||||
|
||||
With no ``--profile``, lists every stack under ``~/.honcho/profiles/``.
|
||||
"""
|
||||
if json_output:
|
||||
set_json_mode(True)
|
||||
|
||||
if profile_name:
|
||||
name = resolve_profile_name(profile_name)
|
||||
profile = load_profile(name)
|
||||
if not profile.compose_file().exists():
|
||||
_die(
|
||||
"STACK_NOT_FOUND",
|
||||
f"No local stack for profile '{profile.name}'. Run `honcho start` first.",
|
||||
{"profile": profile.name},
|
||||
)
|
||||
if not _status_one(profile):
|
||||
raise typer.Exit(1)
|
||||
return
|
||||
|
||||
names = list_profile_names()
|
||||
if not names:
|
||||
_die(
|
||||
"STACK_NOT_FOUND",
|
||||
"No local stacks. Run `honcho start` first.",
|
||||
)
|
||||
|
||||
if len(names) == 1:
|
||||
if not _status_one(load_profile(names[0])):
|
||||
raise typer.Exit(1)
|
||||
return
|
||||
|
||||
rows: list[dict] = []
|
||||
try:
|
||||
for name in names:
|
||||
profile = load_profile(name)
|
||||
services, running = _inspect(profile)
|
||||
rows.append(
|
||||
_payload(profile, "running" if running else "stopped", services)
|
||||
)
|
||||
except DockerError as e:
|
||||
e.exit()
|
||||
if use_json():
|
||||
print_json({"profiles": rows})
|
||||
return
|
||||
_console.print()
|
||||
print_result(
|
||||
[
|
||||
{
|
||||
"profile": row["profile"],
|
||||
"status": row["status"],
|
||||
"api": row["endpoints"]["api"],
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
columns=["profile", "status", "api"],
|
||||
)
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"""Local Honcho stack: profiles, Compose rendering, Docker, health checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
DEFAULT_PROFILE = "local"
|
||||
DEFAULT_API_PORT = 8000
|
||||
DEFAULT_DB_PORT = 5432
|
||||
DEFAULT_REDIS_PORT = 6379
|
||||
DEFAULT_IMAGE = "ghcr.io/plastic-labs/honcho:latest"
|
||||
DEFAULT_HEALTH_TIMEOUT = 180
|
||||
|
||||
STACK_SERVICES = ("api", "deriver", "database", "redis")
|
||||
|
|
@ -0,0 +1,436 @@
|
|||
"""Docker daemon + Compose helpers for the local stack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from honcho_cli.local import STACK_SERVICES
|
||||
from honcho_cli.local.profile import LocalProfile
|
||||
from honcho_cli.output import print_error
|
||||
|
||||
_DAEMON_DOWN_MARKERS = (
|
||||
"cannot connect to the docker daemon",
|
||||
"is the docker daemon running",
|
||||
"failed to connect to the docker api",
|
||||
"error during connect",
|
||||
)
|
||||
_COMPOSE_MISSING_MARKERS = (
|
||||
"'compose' is not a docker command",
|
||||
"unknown command: compose",
|
||||
"docker: unknown command",
|
||||
)
|
||||
_CRED_HELPER_MARKERS = ("error getting credentials", "docker-credential-desktop")
|
||||
|
||||
|
||||
class DockerError(Exception):
|
||||
"""Docker is missing, the daemon is down, or a Compose command failed."""
|
||||
|
||||
def __init__(self, code: str, message: str, details: dict | None = None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
|
||||
def exit(self) -> None:
|
||||
print_error(self.code, self.message, self.details or None)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def port_available(port: int, host: str = "127.0.0.1") -> bool:
|
||||
"""True when nothing is accepting connections on ``host:port``."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(0.2)
|
||||
return sock.connect_ex((host, port)) != 0
|
||||
|
||||
|
||||
def allocate_host_ports(
|
||||
profile: LocalProfile,
|
||||
*,
|
||||
pinned: frozenset[str] = frozenset(),
|
||||
) -> tuple[LocalProfile, dict[str, tuple[int, int]]]:
|
||||
"""Move api/db/redis host ports that are already bound.
|
||||
|
||||
Names in ``pinned`` (``api`` / ``database`` / ``redis``) were set by a
|
||||
flag and fail instead of moving.
|
||||
"""
|
||||
taken: set[int] = set()
|
||||
remapped: dict[str, tuple[int, int]] = {}
|
||||
chosen: dict[str, int] = {}
|
||||
for name, field, flag in (
|
||||
("api", "api_port", "--api-port"),
|
||||
("database", "db_port", "--db-port"),
|
||||
("redis", "redis_port", "--redis-port"),
|
||||
):
|
||||
preferred = getattr(profile, field)
|
||||
port = preferred
|
||||
if name in pinned:
|
||||
if preferred in taken or not port_available(preferred):
|
||||
raise DockerError(
|
||||
"PORT_IN_USE",
|
||||
f"Host port {preferred} for {name} is already in use. "
|
||||
f"Pass {flag} with a free port, or stop the other process.",
|
||||
{"port": preferred, "service": name, "flag": flag},
|
||||
)
|
||||
else:
|
||||
while port in taken or not port_available(port):
|
||||
port += 1
|
||||
if port > preferred + 100:
|
||||
raise DockerError(
|
||||
"PORT_IN_USE",
|
||||
f"Could not find a free host port near {preferred}.",
|
||||
{"preferred": preferred},
|
||||
)
|
||||
if port != preferred:
|
||||
remapped[name] = (preferred, port)
|
||||
taken.add(port)
|
||||
chosen[field] = port
|
||||
return profile.overlay(**chosen), remapped
|
||||
|
||||
|
||||
def compose_argv(profile: LocalProfile) -> list[str]:
|
||||
return [
|
||||
"docker",
|
||||
"compose",
|
||||
"-f",
|
||||
str(profile.compose_file()),
|
||||
"--project-directory",
|
||||
str(profile.dir()),
|
||||
"-p",
|
||||
profile.project_name,
|
||||
]
|
||||
|
||||
|
||||
_CONFIG_PATHS = ("/app/config.toml.example", "/app/config.toml")
|
||||
_CONFIG_HEADER = (
|
||||
"# Copied from {image} by honcho start. This file is not overwritten on later starts.\n"
|
||||
"# Secrets belong in .env (environment variables win over this file).\n\n"
|
||||
)
|
||||
|
||||
|
||||
def image_is_digest(ref: str) -> bool:
|
||||
"""True when ``ref`` is already pinned to a content digest."""
|
||||
return "@sha256:" in ref.lower()
|
||||
|
||||
|
||||
def image_repository(ref: str) -> str:
|
||||
"""Strip a tag or digest from a Docker image reference."""
|
||||
if "@" in ref:
|
||||
return ref.split("@", 1)[0]
|
||||
last_slash = ref.rfind("/")
|
||||
last_colon = ref.rfind(":")
|
||||
if last_colon > last_slash:
|
||||
return ref[:last_colon]
|
||||
return ref
|
||||
|
||||
|
||||
def pin_image(image: str) -> str:
|
||||
"""Pull ``image`` if needed and return a digest-pinned reference.
|
||||
|
||||
``ghcr.io/plastic-labs/honcho:latest`` becomes
|
||||
``ghcr.io/plastic-labs/honcho@sha256:...`` so the profile does not
|
||||
float when ``:latest`` moves. Already-pinned refs are left alone.
|
||||
"""
|
||||
if image_is_digest(image):
|
||||
if not _image_exists(image):
|
||||
_pull(image)
|
||||
return image
|
||||
_pull(image)
|
||||
digest = _repo_digest(image)
|
||||
if not digest:
|
||||
raise DockerError(
|
||||
"IMAGE_PIN_FAILED",
|
||||
f"Pulled {image} but could not resolve a registry digest to pin.",
|
||||
{"image": image},
|
||||
)
|
||||
return digest
|
||||
|
||||
|
||||
def seed_config_toml(profile: LocalProfile) -> bool:
|
||||
"""Copy the image's ``config.toml.example`` into the profile if missing.
|
||||
|
||||
Returns True when a file was written. Never overwrites an existing
|
||||
``config.toml``.
|
||||
"""
|
||||
dest = profile.config_file()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if dest.exists():
|
||||
return False
|
||||
copied = _copy_from_image(profile.image, _CONFIG_PATHS)
|
||||
if copied is None:
|
||||
raise DockerError(
|
||||
"CONFIG_MISSING",
|
||||
f"Could not copy config.toml from {profile.image}.",
|
||||
{"image": profile.image},
|
||||
)
|
||||
dest.write_text(_CONFIG_HEADER.format(image=profile.image) + copied)
|
||||
return True
|
||||
|
||||
|
||||
def compose_up(
|
||||
profile: LocalProfile,
|
||||
*,
|
||||
recreate: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
"""``docker compose up -d``. Compose output goes to stderr.
|
||||
|
||||
``recreate`` names services to ``--force-recreate`` (used after ``--setup``
|
||||
on an already-running stack so new ``.env`` values take effect).
|
||||
"""
|
||||
args = ["up", "-d"]
|
||||
if recreate:
|
||||
args.extend(["--force-recreate", *recreate])
|
||||
_run_compose(profile, args)
|
||||
|
||||
|
||||
def compose_down(profile: LocalProfile, *, wipe: bool = False) -> None:
|
||||
args = ["down"]
|
||||
if wipe:
|
||||
args.append("-v")
|
||||
_run_compose(profile, args, capture=False)
|
||||
|
||||
|
||||
def compose_ps(profile: LocalProfile) -> list[dict]:
|
||||
"""Parsed ``docker compose ps --format json`` (array or NDJSON)."""
|
||||
proc = _run_compose(profile, ["ps", "--format", "json"], capture=True, check=False)
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
return _parse_ps(proc.stdout or "")
|
||||
|
||||
|
||||
def services_running(ps: list[dict]) -> dict[str, str]:
|
||||
"""Map service name → state for the four stack services.
|
||||
|
||||
State is ``running``, ``healthy``, ``exited``, etc. Prefer Docker's
|
||||
Health field when present.
|
||||
"""
|
||||
out: dict[str, str] = {}
|
||||
for row in ps:
|
||||
service = str(row.get("Service") or row.get("Name") or "")
|
||||
# "honcho-local-api-1" → try Service first; fall back to suffix match
|
||||
if service not in STACK_SERVICES:
|
||||
for name in STACK_SERVICES:
|
||||
if (
|
||||
service == name
|
||||
or service.endswith(f"-{name}-1")
|
||||
or f"_{name}_" in service
|
||||
):
|
||||
service = name
|
||||
break
|
||||
else:
|
||||
continue
|
||||
health = str(row.get("Health") or "").lower()
|
||||
state = str(row.get("State") or row.get("Status") or "").lower()
|
||||
if health:
|
||||
out[service] = health
|
||||
elif "health" in state:
|
||||
# e.g. "running (healthy)"
|
||||
out[service] = state
|
||||
else:
|
||||
out[service] = state or "unknown"
|
||||
return out
|
||||
|
||||
|
||||
def stack_containers_up(ps: list[dict]) -> bool:
|
||||
"""True when all four services are running (deriver has no healthcheck)."""
|
||||
states = services_running(ps)
|
||||
if any(name not in states for name in STACK_SERVICES):
|
||||
return False
|
||||
for state in states.values():
|
||||
if "exit" in state or state in {"dead", "paused"}:
|
||||
return False
|
||||
if "running" not in state and "healthy" not in state:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _unavailable(proc: subprocess.CompletedProcess[str]) -> DockerError | None:
|
||||
"""Map a failed docker/compose process to a user-facing error, if obvious."""
|
||||
text = f"{proc.stderr or ''}{proc.stdout or ''}"
|
||||
lower = text.lower()
|
||||
if any(marker in lower for marker in _DAEMON_DOWN_MARKERS):
|
||||
return DockerError(
|
||||
"DOCKER_NOT_RUNNING",
|
||||
"Docker is installed but the daemon is not running. Start it and retry.",
|
||||
)
|
||||
if any(marker in lower for marker in _COMPOSE_MISSING_MARKERS):
|
||||
return DockerError(
|
||||
"DOCKER_COMPOSE_MISSING",
|
||||
"Honcho start requires Docker Compose v2 (the `docker compose` plugin).",
|
||||
)
|
||||
if any(marker in text for marker in _CRED_HELPER_MARKERS):
|
||||
return DockerError(
|
||||
"DOCKER_CREDENTIALS",
|
||||
"Docker could not read registry credentials "
|
||||
"(docker-credential-desktop is not on PATH). "
|
||||
"Quit and reopen your terminal, or add Docker Desktop's bin "
|
||||
"directory to PATH, then retry.",
|
||||
{"exit_code": proc.returncode},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _run_compose(
|
||||
profile: LocalProfile,
|
||||
args: list[str],
|
||||
*,
|
||||
capture: bool = False,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
cmd = compose_argv(profile) + args
|
||||
cwd: Path = profile.dir()
|
||||
try:
|
||||
proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||
except FileNotFoundError as e:
|
||||
raise DockerError(
|
||||
"DOCKER_NOT_INSTALLED",
|
||||
"Docker is not installed. Install Docker Desktop (or another Compose-v2 runtime) and retry.",
|
||||
) from e
|
||||
except OSError as e:
|
||||
raise DockerError("COMPOSE_FAILED", str(e), {"command": cmd}) from e
|
||||
if not capture:
|
||||
if proc.stdout:
|
||||
sys.stderr.write(proc.stdout)
|
||||
if proc.stderr:
|
||||
sys.stderr.write(proc.stderr)
|
||||
if proc.returncode != 0:
|
||||
classified = _unavailable(proc)
|
||||
if classified is not None:
|
||||
raise classified
|
||||
if check and proc.returncode != 0:
|
||||
raise DockerError(
|
||||
"COMPOSE_FAILED",
|
||||
"docker compose failed. See output above, or run `docker compose -p "
|
||||
f"{profile.project_name} logs`.",
|
||||
{"project": profile.project_name, "exit_code": proc.returncode},
|
||||
)
|
||||
return proc
|
||||
|
||||
|
||||
def _run_docker(
|
||||
args: list[str],
|
||||
*,
|
||||
check: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["docker", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise DockerError(
|
||||
"DOCKER_NOT_INSTALLED",
|
||||
"Docker is not installed. Install Docker Desktop (or another Compose-v2 runtime) and retry.",
|
||||
) from e
|
||||
except OSError as e:
|
||||
raise DockerError("DOCKER_FAILED", str(e), {"command": args}) from e
|
||||
if proc.returncode == 0:
|
||||
return proc
|
||||
classified = _unavailable(proc)
|
||||
if classified is not None:
|
||||
raise classified
|
||||
if check:
|
||||
raise DockerError(
|
||||
"DOCKER_FAILED",
|
||||
f"docker {' '.join(args)} failed.",
|
||||
{
|
||||
"exit_code": proc.returncode,
|
||||
"stderr": (proc.stderr or "")[-500:],
|
||||
},
|
||||
)
|
||||
return proc
|
||||
|
||||
|
||||
def _pull(image: str) -> None:
|
||||
proc = _run_docker(["pull", image], check=False)
|
||||
if proc.stdout:
|
||||
sys.stderr.write(proc.stdout)
|
||||
if proc.stderr:
|
||||
sys.stderr.write(proc.stderr)
|
||||
if proc.returncode != 0:
|
||||
raise DockerError(
|
||||
"IMAGE_PULL_FAILED",
|
||||
f"Failed to pull {image}.",
|
||||
{"image": image, "exit_code": proc.returncode},
|
||||
)
|
||||
|
||||
|
||||
def _image_exists(image: str) -> bool:
|
||||
return _run_docker(["image", "inspect", image], check=False).returncode == 0
|
||||
|
||||
|
||||
def _repo_digest(image: str) -> str | None:
|
||||
proc = _run_docker(
|
||||
["image", "inspect", "--format", "{{json .RepoDigests}}", image],
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
digests = json.loads((proc.stdout or "").strip() or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(digests, list):
|
||||
return None
|
||||
repo = image_repository(image)
|
||||
for item in digests:
|
||||
if isinstance(item, str) and item.startswith(repo + "@"):
|
||||
return item
|
||||
for item in digests:
|
||||
if isinstance(item, str) and "@sha256:" in item:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _copy_from_image(image: str, paths: tuple[str, ...]) -> str | None:
|
||||
"""Create a stopped container and copy the first path that exists."""
|
||||
name = f"honcho-seed-{os.getpid()}-{time.time_ns()}"
|
||||
created = _run_docker(["create", "--name", name, image], check=False)
|
||||
if created.returncode != 0:
|
||||
cid = (created.stdout or "").strip() or name
|
||||
_run_docker(["rm", "-f", cid], check=False)
|
||||
return None
|
||||
cid = (created.stdout or "").strip() or name
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="honcho-cfg-") as tmp:
|
||||
dest = Path(tmp) / "config.toml"
|
||||
for path in paths:
|
||||
if dest.exists():
|
||||
dest.unlink()
|
||||
copied = _run_docker(["cp", f"{cid}:{path}", str(dest)], check=False)
|
||||
if copied.returncode == 0 and dest.exists():
|
||||
return dest.read_text(encoding="utf-8")
|
||||
finally:
|
||||
_run_docker(["rm", "-f", cid], check=False)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_ps(stdout: str) -> list[dict]:
|
||||
text = stdout.strip()
|
||||
if not text:
|
||||
return []
|
||||
if text.startswith("["):
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
rows: list[dict] = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(row, dict):
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
"""Render Compose + ``.env`` for a local stack profile."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import suppress
|
||||
from importlib.resources import files
|
||||
from pathlib import Path
|
||||
|
||||
from honcho_cli.local.profile import LocalProfile
|
||||
|
||||
# Keys honcho start owns. Unknown lines in an existing .env are preserved.
|
||||
MANAGED_KEYS = (
|
||||
"AUTH_USE_AUTH",
|
||||
"LOG_LEVEL",
|
||||
"HONCHO_IMAGE",
|
||||
"API_PORT",
|
||||
"DB_PORT",
|
||||
"REDIS_PORT",
|
||||
)
|
||||
|
||||
# Host env forwarded into the profile .env (overrides config.toml).
|
||||
_SETTINGS_PREFIXES = (
|
||||
"LLM_",
|
||||
"EMBEDDING_",
|
||||
"DERIVER_",
|
||||
"DIALECTIC_",
|
||||
"DREAM_",
|
||||
"SUMMARY_",
|
||||
)
|
||||
_LLM_KEYS = (
|
||||
"LLM_OPENAI_API_KEY",
|
||||
"LLM_ANTHROPIC_API_KEY",
|
||||
"LLM_GEMINI_API_KEY",
|
||||
)
|
||||
|
||||
_HEADER = (
|
||||
"# Generated by honcho start. Extra keys below the managed block are preserved."
|
||||
)
|
||||
|
||||
_PLACEHOLDERS = frozenset(
|
||||
{
|
||||
"",
|
||||
"your-api-key-here",
|
||||
"changeme",
|
||||
"sk-...",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_placeholder_key(value: str | None) -> bool:
|
||||
"""True when ``value`` is missing or a known template placeholder."""
|
||||
if value is None:
|
||||
return True
|
||||
return value.strip() in _PLACEHOLDERS
|
||||
|
||||
|
||||
def settings_from_environ() -> dict[str, str]:
|
||||
"""Host env vars that map to Honcho settings. Empty/placeholder values skipped."""
|
||||
return {
|
||||
k: v
|
||||
for k, v in os.environ.items()
|
||||
if k.startswith(_SETTINGS_PREFIXES) and not is_placeholder_key(v)
|
||||
}
|
||||
|
||||
|
||||
def read_env_file(path: Path) -> dict[str, str]:
|
||||
"""Parse a dotenv file into a dict. Last assignment of a key wins."""
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
k, _, v = stripped.partition("=")
|
||||
out[k.strip()] = _unquote(v.strip())
|
||||
return out
|
||||
|
||||
|
||||
def read_env_value(path: Path, key: str) -> str | None:
|
||||
"""Return the raw value for ``key`` in a dotenv file, or None."""
|
||||
return read_env_file(path).get(key)
|
||||
|
||||
|
||||
def has_provider_key(profile: LocalProfile, extra: dict[str, str]) -> bool:
|
||||
"""True when host extra or profile ``.env`` has a real LLM API key."""
|
||||
stored = {**read_env_file(profile.env_file()), **extra}
|
||||
return any(not is_placeholder_key(stored.get(k)) for k in _LLM_KEYS)
|
||||
|
||||
|
||||
def managed_env(profile: LocalProfile) -> dict[str, str]:
|
||||
"""Values written into the managed block of ``.env``."""
|
||||
return {
|
||||
"AUTH_USE_AUTH": "false",
|
||||
"LOG_LEVEL": "INFO",
|
||||
"HONCHO_IMAGE": profile.image,
|
||||
"API_PORT": str(profile.api_port),
|
||||
"DB_PORT": str(profile.db_port),
|
||||
"REDIS_PORT": str(profile.redis_port),
|
||||
}
|
||||
|
||||
|
||||
def upsert_env(
|
||||
path: Path,
|
||||
updates: dict[str, str],
|
||||
*,
|
||||
drop: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
"""Write ``updates``, preserving unrelated user lines.
|
||||
|
||||
Managed keys are written first (stable order), then any other keys in
|
||||
``updates``. Keys in ``drop`` are removed and not rewritten. Drops a
|
||||
previous generated header so it is not duplicated.
|
||||
"""
|
||||
drop_set = frozenset(drop)
|
||||
extras: list[str] = []
|
||||
if path.exists():
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped == _HEADER or stripped.startswith(
|
||||
"# Generated by honcho start"
|
||||
):
|
||||
continue
|
||||
if not stripped or stripped.startswith("#"):
|
||||
extras.append(line)
|
||||
continue
|
||||
if "=" in stripped:
|
||||
k, _, _ = stripped.partition("=")
|
||||
name = k.strip()
|
||||
if name in updates or name in MANAGED_KEYS or name in drop_set:
|
||||
continue
|
||||
extras.append(line)
|
||||
|
||||
managed = [f"{k}={updates[k]}" for k in MANAGED_KEYS if k in updates]
|
||||
extra_updates = [
|
||||
f"{k}={v}" for k, v in updates.items() if k not in MANAGED_KEYS
|
||||
]
|
||||
body = [_HEADER, *managed]
|
||||
if extra_updates:
|
||||
if not body[-1].startswith("#"):
|
||||
body.append("")
|
||||
body.extend(extra_updates)
|
||||
if extras:
|
||||
# Keep a blank line between generated and user keys when there are extras.
|
||||
if extras[0].strip():
|
||||
body.append("")
|
||||
body.extend(extras)
|
||||
path.write_text("\n".join(body) + "\n")
|
||||
with suppress(OSError):
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def render_stack(
|
||||
profile: LocalProfile,
|
||||
extra: dict[str, str] | None = None,
|
||||
drop: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
"""Write compose, init.sql, and .env into the profile directory."""
|
||||
directory = profile.dir()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
with suppress(OSError):
|
||||
os.chmod(directory, 0o700)
|
||||
|
||||
templates = files("honcho_cli.local.templates")
|
||||
compose = templates.joinpath("docker-compose.yml").read_text(encoding="utf-8")
|
||||
init_sql = templates.joinpath("init.sql").read_text(encoding="utf-8")
|
||||
profile.compose_file().write_text(compose)
|
||||
(directory / "init.sql").write_text(init_sql)
|
||||
updates = managed_env(profile)
|
||||
if extra:
|
||||
updates.update(extra)
|
||||
upsert_env(profile.env_file(), updates, drop=drop)
|
||||
|
||||
|
||||
def _unquote(value: str) -> str:
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
"""Poll the local API health endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from honcho_cli.local.docker import compose_ps, services_running, stack_containers_up
|
||||
from honcho_cli.local.profile import LocalProfile
|
||||
|
||||
|
||||
def api_healthy(base_url: str, *, timeout: float = 2.0) -> bool:
|
||||
"""True when ``GET /health`` returns HTTP 200."""
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
response = client.get(base_url.rstrip("/") + "/health")
|
||||
return response.status_code == 200
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
|
||||
def stack_healthy(profile: LocalProfile) -> bool:
|
||||
"""True when Compose services are up and the API answers /health."""
|
||||
if not profile.compose_file().exists():
|
||||
return False
|
||||
ps = compose_ps(profile)
|
||||
if not stack_containers_up(ps):
|
||||
return False
|
||||
return api_healthy(profile.base_url)
|
||||
|
||||
|
||||
def wait_for_health(
|
||||
profile: LocalProfile,
|
||||
*,
|
||||
timeout: float,
|
||||
interval: float = 1.0,
|
||||
) -> bool:
|
||||
"""Poll until the API is healthy or ``timeout`` seconds elapse.
|
||||
|
||||
Returns False on timeout. Fails fast if a required container has exited.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
ps = compose_ps(profile)
|
||||
states = services_running(ps)
|
||||
for _name, state in states.items():
|
||||
if "exit" in state or state in {"dead"}:
|
||||
return False
|
||||
if api_healthy(profile.base_url) and stack_containers_up(ps):
|
||||
return True
|
||||
time.sleep(interval)
|
||||
return api_healthy(profile.base_url)
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
"""Named local-stack profiles under ``$HONCHO_CONFIG_DIR/profiles``.
|
||||
|
||||
A profile is a Compose project directory, not an auth identity.
|
||||
Resolution: ``--profile`` > ``HONCHO_PROFILE`` > ``local``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from honcho_cli.local import (
|
||||
DEFAULT_API_PORT,
|
||||
DEFAULT_DB_PORT,
|
||||
DEFAULT_IMAGE,
|
||||
DEFAULT_PROFILE,
|
||||
DEFAULT_REDIS_PORT,
|
||||
)
|
||||
from honcho_cli.output import print_error
|
||||
|
||||
_PROFILE_NAME = re.compile(r"^[a-z][a-z0-9_-]{0,62}$")
|
||||
|
||||
|
||||
def profiles_dir():
|
||||
from honcho_cli import config as cfg
|
||||
|
||||
return cfg.CONFIG_DIR / "profiles"
|
||||
|
||||
|
||||
def validate_profile_name(name: str) -> str:
|
||||
if name and _PROFILE_NAME.match(name):
|
||||
return name
|
||||
print_error(
|
||||
"INVALID_PROFILE",
|
||||
"Profile name must be lowercase alphanumeric, starting with a letter "
|
||||
"(hyphens and underscores allowed).",
|
||||
{"profile": name},
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def resolve_profile_name(flag: str | None) -> str:
|
||||
raw = (
|
||||
(flag or "").strip()
|
||||
or (os.environ.get("HONCHO_PROFILE") or "").strip()
|
||||
or DEFAULT_PROFILE
|
||||
)
|
||||
return validate_profile_name(raw)
|
||||
|
||||
|
||||
def list_profile_names() -> list[str]:
|
||||
"""Profile directories that already have a Compose file."""
|
||||
root = profiles_dir()
|
||||
if not root.is_dir():
|
||||
return []
|
||||
names: list[str] = []
|
||||
for path in sorted(root.iterdir()):
|
||||
if (
|
||||
path.is_dir()
|
||||
and _PROFILE_NAME.match(path.name)
|
||||
and (path / "docker-compose.yml").exists()
|
||||
):
|
||||
names.append(path.name)
|
||||
return names
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalProfile:
|
||||
"""Ports and image for one local stack."""
|
||||
|
||||
name: str
|
||||
api_port: int = DEFAULT_API_PORT
|
||||
db_port: int = DEFAULT_DB_PORT
|
||||
redis_port: int = DEFAULT_REDIS_PORT
|
||||
image: str = DEFAULT_IMAGE
|
||||
|
||||
@property
|
||||
def project_name(self) -> str:
|
||||
return f"honcho-{self.name}"
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return f"http://127.0.0.1:{self.api_port}"
|
||||
|
||||
def dir(self):
|
||||
return profiles_dir() / self.name
|
||||
|
||||
def compose_file(self):
|
||||
return self.dir() / "docker-compose.yml"
|
||||
|
||||
def env_file(self):
|
||||
return self.dir() / ".env"
|
||||
|
||||
def profile_file(self):
|
||||
return self.dir() / "profile.json"
|
||||
|
||||
def config_file(self):
|
||||
return self.dir() / "config.toml"
|
||||
|
||||
def endpoints(self) -> dict[str, str]:
|
||||
return {
|
||||
"api": self.base_url,
|
||||
"docs": f"{self.base_url}/docs",
|
||||
"postgres": f"postgresql://postgres:postgres@127.0.0.1:{self.db_port}/postgres",
|
||||
"redis": f"redis://127.0.0.1:{self.redis_port}/0",
|
||||
}
|
||||
|
||||
def overlay(self, **fields) -> LocalProfile:
|
||||
return replace(self, **{k: v for k, v in fields.items() if v is not None})
|
||||
|
||||
|
||||
def load_profile(name: str) -> LocalProfile:
|
||||
profile = LocalProfile(name=validate_profile_name(name))
|
||||
path = profile.profile_file()
|
||||
if not path.exists():
|
||||
return profile
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return profile
|
||||
if not isinstance(data, dict):
|
||||
return profile
|
||||
image = data.get("image")
|
||||
return replace(
|
||||
profile,
|
||||
api_port=_port(data.get("apiPort"), profile.api_port),
|
||||
db_port=_port(data.get("dbPort"), profile.db_port),
|
||||
redis_port=_port(data.get("redisPort"), profile.redis_port),
|
||||
image=image if isinstance(image, str) and image else profile.image,
|
||||
)
|
||||
|
||||
|
||||
def save_profile(profile: LocalProfile) -> None:
|
||||
directory = profile.dir()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
with suppress(OSError):
|
||||
os.chmod(directory, 0o700)
|
||||
payload = {
|
||||
"apiPort": profile.api_port,
|
||||
"dbPort": profile.db_port,
|
||||
"redisPort": profile.redis_port,
|
||||
"image": profile.image,
|
||||
}
|
||||
profile.profile_file().write_text(json.dumps(payload, indent=2) + "\n")
|
||||
|
||||
|
||||
def _port(value: object, default: int) -> int:
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
try:
|
||||
parsed = int(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return parsed if 1 <= parsed <= 65535 else default
|
||||
|
|
@ -0,0 +1,531 @@
|
|||
"""Interactive ``honcho start --setup`` wizard.
|
||||
|
||||
Writes curated LLM/feature overrides for the local stack. Secrets and knobs
|
||||
go to the profile ``.env`` (env wins over ``config.toml``). Prompts are TTY
|
||||
only — the start command rejects ``--setup`` in JSON / non-TTY mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from honcho_cli.local.env import is_placeholder_key, read_env_file, settings_from_environ
|
||||
from honcho_cli.output import print_error
|
||||
|
||||
SETUP_MODES = ("basic", "advanced")
|
||||
DIALECTIC_LEVELS = ("minimal", "low", "medium", "high", "max")
|
||||
PROVIDERS = ("openai", "anthropic", "gemini", "openai-compatible")
|
||||
EMBEDDING_TRANSPORTS = ("openai", "gemini")
|
||||
|
||||
_CHAT_PREFIXES = (
|
||||
"DERIVER_MODEL_CONFIG",
|
||||
"SUMMARY_MODEL_CONFIG",
|
||||
"DREAM_DEDUCTION_MODEL_CONFIG",
|
||||
"DREAM_INDUCTION_MODEL_CONFIG",
|
||||
*(f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG" for level in DIALECTIC_LEVELS),
|
||||
)
|
||||
|
||||
_PROVIDER_KEY_ENV = {
|
||||
"openai": "LLM_OPENAI_API_KEY",
|
||||
"openai-compatible": "LLM_OPENAI_API_KEY",
|
||||
"anthropic": "LLM_ANTHROPIC_API_KEY",
|
||||
"gemini": "LLM_GEMINI_API_KEY",
|
||||
}
|
||||
|
||||
_console = Console(stderr=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TomlSetupDefaults:
|
||||
"""Model/feature defaults copied from the image ``config.toml``.
|
||||
|
||||
Honcho only ships OpenAI chat/embedding defaults. Other providers have
|
||||
no suggested model in that file — the wizard does not invent one.
|
||||
"""
|
||||
|
||||
chat_transport: str | None = None
|
||||
chat_model: str | None = None
|
||||
embed_transport: str | None = None
|
||||
embed_model: str | None = None
|
||||
embed_dims: int | None = None
|
||||
dreams_enabled: bool | None = None
|
||||
flush_enabled: bool | None = None
|
||||
|
||||
|
||||
def load_toml_setup_defaults(path: Path | None) -> TomlSetupDefaults:
|
||||
"""Read prompt defaults from the profile ``config.toml`` (image-aligned)."""
|
||||
if path is None or not path.is_file():
|
||||
return TomlSetupDefaults()
|
||||
try:
|
||||
with path.open("rb") as fh:
|
||||
data = tomllib.load(fh)
|
||||
deriver = data.get("deriver") or {}
|
||||
chat = deriver.get("model_config") or {}
|
||||
embedding = data.get("embedding") or {}
|
||||
embed = embedding.get("model_config") or {}
|
||||
dream = data.get("dream") or {}
|
||||
dims = embedding.get("VECTOR_DIMENSIONS")
|
||||
return TomlSetupDefaults(
|
||||
chat_transport=chat.get("transport"),
|
||||
chat_model=chat.get("model"),
|
||||
embed_transport=embed.get("transport"),
|
||||
embed_model=embed.get("model"),
|
||||
embed_dims=dims if isinstance(dims, int) and dims > 0 else None,
|
||||
dreams_enabled=dream.get("ENABLED"),
|
||||
flush_enabled=deriver.get("FLUSH_ENABLED"),
|
||||
)
|
||||
except (OSError, tomllib.TOMLDecodeError, TypeError, AttributeError):
|
||||
return TomlSetupDefaults()
|
||||
|
||||
|
||||
def chat_model_default(
|
||||
provider: str,
|
||||
env: dict[str, str],
|
||||
toml: TomlSetupDefaults,
|
||||
*,
|
||||
inferred: str | None = None,
|
||||
) -> str:
|
||||
"""Prefer a previous wizard choice, else the image toml when transports match."""
|
||||
if inferred is None:
|
||||
inferred = infer_provider(env)
|
||||
if inferred == provider:
|
||||
current = env.get("DERIVER_MODEL_CONFIG__MODEL")
|
||||
if current:
|
||||
return current
|
||||
if toml.chat_model and _provider_matches_transport(provider, toml.chat_transport):
|
||||
return toml.chat_model
|
||||
return ""
|
||||
|
||||
|
||||
def _provider_matches_transport(provider: str, transport: str | None) -> bool:
|
||||
if not transport:
|
||||
return False
|
||||
return transport_of(provider) == transport
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SetupAnswers:
|
||||
"""Curated knobs collected by the wizard (or tests)."""
|
||||
|
||||
mode: str
|
||||
provider: str
|
||||
api_key: str
|
||||
chat_model: str
|
||||
base_url: str | None = None
|
||||
embedding_api_key: str | None = None
|
||||
embedding_key_transport: str | None = None
|
||||
embedding_transport: str | None = None
|
||||
embedding_model: str | None = None
|
||||
embedding_dimensions: int | None = None
|
||||
deriver_model: str | None = None
|
||||
dialectic_model: str | None = None
|
||||
dreams_enabled: bool | None = None
|
||||
flush_enabled: bool | None = None
|
||||
|
||||
|
||||
def transport_of(provider: str) -> str:
|
||||
"""Honcho ``MODEL_CONFIG.transport`` for a wizard provider id."""
|
||||
return "openai" if provider == "openai-compatible" else provider
|
||||
|
||||
|
||||
def answers_to_env(answers: SetupAnswers) -> dict[str, str]:
|
||||
"""Map wizard answers to Honcho env overrides."""
|
||||
transport = transport_of(answers.provider)
|
||||
env: dict[str, str] = {}
|
||||
|
||||
env[_PROVIDER_KEY_ENV[answers.provider]] = answers.api_key
|
||||
if answers.base_url:
|
||||
env["LLM_OPENAI_BASE_URL"] = answers.base_url
|
||||
# Embeddings do not inherit this URL; write it so OpenRouter/vLLM
|
||||
# keys are not sent to api.openai.com.
|
||||
if (answers.embedding_transport or "openai") == "openai":
|
||||
env["EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL"] = answers.base_url
|
||||
|
||||
if answers.embedding_api_key and answers.embedding_key_transport:
|
||||
embed_key = (
|
||||
"LLM_OPENAI_API_KEY"
|
||||
if answers.embedding_key_transport == "openai"
|
||||
else "LLM_GEMINI_API_KEY"
|
||||
)
|
||||
env[embed_key] = answers.embedding_api_key
|
||||
|
||||
for prefix in _CHAT_PREFIXES:
|
||||
env[f"{prefix}__TRANSPORT"] = transport
|
||||
env[f"{prefix}__MODEL"] = answers.chat_model
|
||||
|
||||
if answers.deriver_model:
|
||||
env["DERIVER_MODEL_CONFIG__TRANSPORT"] = transport
|
||||
env["DERIVER_MODEL_CONFIG__MODEL"] = answers.deriver_model
|
||||
|
||||
if answers.dialectic_model:
|
||||
for level in DIALECTIC_LEVELS:
|
||||
env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__TRANSPORT"] = transport
|
||||
env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__MODEL"] = (
|
||||
answers.dialectic_model
|
||||
)
|
||||
|
||||
if answers.embedding_transport:
|
||||
env["EMBEDDING_MODEL_CONFIG__TRANSPORT"] = answers.embedding_transport
|
||||
if answers.embedding_model:
|
||||
env["EMBEDDING_MODEL_CONFIG__MODEL"] = answers.embedding_model
|
||||
if answers.embedding_dimensions is not None:
|
||||
env["EMBEDDING_VECTOR_DIMENSIONS"] = str(answers.embedding_dimensions)
|
||||
elif answers.embedding_key_transport == "gemini":
|
||||
# Basic + Anthropic chat: a Gemini key is unused unless embeddings switch.
|
||||
env["EMBEDDING_MODEL_CONFIG__TRANSPORT"] = "gemini"
|
||||
|
||||
if answers.dreams_enabled is not None:
|
||||
env["DREAM_ENABLED"] = "true" if answers.dreams_enabled else "false"
|
||||
if answers.flush_enabled is not None:
|
||||
env["DERIVER_FLUSH_ENABLED"] = "true" if answers.flush_enabled else "false"
|
||||
return env
|
||||
|
||||
|
||||
def answers_drop_keys(answers: SetupAnswers) -> tuple[str, ...]:
|
||||
"""Keys to remove so a previous wizard run cannot leak into this one."""
|
||||
drop: list[str] = []
|
||||
if answers.provider != "openai-compatible":
|
||||
drop.append("LLM_OPENAI_BASE_URL")
|
||||
embed_openai = (answers.embedding_transport or "openai") == "openai"
|
||||
if answers.provider != "openai-compatible" or not embed_openai:
|
||||
drop.append("EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL")
|
||||
return tuple(drop)
|
||||
|
||||
|
||||
def run_setup(
|
||||
mode: str,
|
||||
env_path: Path,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> SetupAnswers:
|
||||
"""Prompt for ``basic`` or ``advanced`` knobs. Enter keeps the default."""
|
||||
env = read_env_file(env_path)
|
||||
env.update(settings_from_environ())
|
||||
defaults = load_toml_setup_defaults(config_path)
|
||||
_console.print()
|
||||
_console.print(
|
||||
" [dim]Configure the local stack. Press Enter to keep the default.[/dim]"
|
||||
)
|
||||
_console.print(
|
||||
" [dim]These values go in .env (they override config.toml).[/dim]"
|
||||
)
|
||||
_console.print()
|
||||
|
||||
inferred = infer_provider(env)
|
||||
provider = _choose(
|
||||
"LLM provider",
|
||||
[
|
||||
("openai", "OpenAI"),
|
||||
("anthropic", "Anthropic"),
|
||||
("gemini", "Gemini"),
|
||||
("openai-compatible", "OpenAI-compatible (OpenRouter, vLLM, Ollama, …)"),
|
||||
],
|
||||
inferred if inferred in PROVIDERS else "openai",
|
||||
)
|
||||
|
||||
base_url: str | None = None
|
||||
if provider == "openai-compatible":
|
||||
base_url = _prompt_text(
|
||||
"OpenAI-compatible base URL",
|
||||
env.get("LLM_OPENAI_BASE_URL") or "https://openrouter.ai/api/v1",
|
||||
)
|
||||
|
||||
key_env = _PROVIDER_KEY_ENV[provider]
|
||||
api_key = _prompt_secret("API key", env.get(key_env))
|
||||
|
||||
chat_default = chat_model_default(
|
||||
provider, env, defaults, inferred=inferred
|
||||
)
|
||||
chat_model = _prompt_text(
|
||||
"Chat model (deriver, dialectic, summary, dream)",
|
||||
chat_default,
|
||||
required=True,
|
||||
)
|
||||
|
||||
embedding_api_key: str | None = None
|
||||
embedding_key_transport: str | None = None
|
||||
embedding_transport: str | None = None
|
||||
embedding_model: str | None = None
|
||||
embedding_dimensions: int | None = None
|
||||
deriver_model: str | None = None
|
||||
dialectic_model: str | None = None
|
||||
dreams_enabled: bool | None = None
|
||||
flush_enabled: bool | None = None
|
||||
|
||||
if mode == "advanced":
|
||||
embedding_transport = _choose(
|
||||
"Embedding provider",
|
||||
[("openai", "OpenAI"), ("gemini", "Gemini")],
|
||||
_default_embedding_transport(provider, env, defaults),
|
||||
)
|
||||
same_embed = env.get("EMBEDDING_MODEL_CONFIG__TRANSPORT") == embedding_transport
|
||||
current_embed = env.get("EMBEDDING_MODEL_CONFIG__MODEL") if same_embed else None
|
||||
embed_from_toml = (
|
||||
defaults.embed_model
|
||||
if defaults.embed_transport == embedding_transport
|
||||
else None
|
||||
)
|
||||
embedding_model = (
|
||||
_prompt_text("Embedding model", current_embed or embed_from_toml or "")
|
||||
or None
|
||||
)
|
||||
dim_default = (
|
||||
int(env["EMBEDDING_VECTOR_DIMENSIONS"])
|
||||
if env.get("EMBEDDING_VECTOR_DIMENSIONS", "").isdigit()
|
||||
else (defaults.embed_dims or 1536)
|
||||
)
|
||||
embedding_dimensions = _prompt_int("Embedding dimensions", dim_default)
|
||||
embedding_key_transport, embedding_api_key = _embedding_key_if_needed(
|
||||
provider, embedding_transport, env
|
||||
)
|
||||
deriver_model = _prompt_text("Deriver model", chat_model)
|
||||
dialectic_model = _prompt_text("Dialectic model (all reasoning levels)", chat_model)
|
||||
dreams_enabled = _choose_bool(
|
||||
"Dreams (periodic deeper reasoning)",
|
||||
_env_bool(
|
||||
env.get("DREAM_ENABLED"),
|
||||
default=True if defaults.dreams_enabled is None else defaults.dreams_enabled,
|
||||
),
|
||||
)
|
||||
flush_enabled = _choose_bool(
|
||||
"Snappy local deriver (flush work immediately, skip batching)",
|
||||
_env_bool(
|
||||
env.get("DERIVER_FLUSH_ENABLED"),
|
||||
default=False if defaults.flush_enabled is None else defaults.flush_enabled,
|
||||
),
|
||||
)
|
||||
elif provider == "anthropic":
|
||||
embedding_key_transport = _choose(
|
||||
"Embeddings (Anthropic has none — pick a provider)",
|
||||
[("openai", "OpenAI"), ("gemini", "Gemini")],
|
||||
"openai",
|
||||
)
|
||||
embed_key_env = _PROVIDER_KEY_ENV[
|
||||
"openai" if embedding_key_transport == "openai" else "gemini"
|
||||
]
|
||||
embedding_api_key = _prompt_secret("Embedding API key", env.get(embed_key_env))
|
||||
|
||||
_console.print()
|
||||
return SetupAnswers(
|
||||
mode=mode,
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
chat_model=chat_model,
|
||||
base_url=base_url,
|
||||
embedding_api_key=embedding_api_key,
|
||||
embedding_key_transport=embedding_key_transport,
|
||||
embedding_transport=embedding_transport,
|
||||
embedding_model=embedding_model,
|
||||
embedding_dimensions=embedding_dimensions,
|
||||
deriver_model=deriver_model,
|
||||
dialectic_model=dialectic_model,
|
||||
dreams_enabled=dreams_enabled,
|
||||
flush_enabled=flush_enabled,
|
||||
)
|
||||
|
||||
|
||||
def infer_provider(env: dict[str, str]) -> str:
|
||||
"""Best-effort provider from an existing profile ``.env``."""
|
||||
if env.get("LLM_OPENAI_BASE_URL"):
|
||||
return "openai-compatible"
|
||||
transport = env.get("DERIVER_MODEL_CONFIG__TRANSPORT")
|
||||
if transport in ("anthropic", "gemini", "openai"):
|
||||
return transport
|
||||
if env.get("LLM_ANTHROPIC_API_KEY") and not env.get("LLM_OPENAI_API_KEY"):
|
||||
return "anthropic"
|
||||
if env.get("LLM_GEMINI_API_KEY") and not env.get("LLM_OPENAI_API_KEY"):
|
||||
return "gemini"
|
||||
return "openai"
|
||||
|
||||
|
||||
def _default_embedding_transport(
|
||||
provider: str, env: dict[str, str], defaults: TomlSetupDefaults
|
||||
) -> str:
|
||||
current = env.get("EMBEDDING_MODEL_CONFIG__TRANSPORT")
|
||||
if current in EMBEDDING_TRANSPORTS:
|
||||
return current
|
||||
if defaults.embed_transport in EMBEDDING_TRANSPORTS:
|
||||
return defaults.embed_transport
|
||||
if provider == "gemini":
|
||||
return "gemini"
|
||||
return "openai"
|
||||
|
||||
|
||||
def _embedding_key_if_needed(
|
||||
chat_provider: str,
|
||||
embed_transport: str,
|
||||
env: dict[str, str],
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Prompt for an embedding key when the chat provider cannot supply it."""
|
||||
chat_transport = transport_of(chat_provider)
|
||||
if embed_transport == chat_transport or (
|
||||
chat_provider == "openai-compatible" and embed_transport == "openai"
|
||||
):
|
||||
return None, None
|
||||
key_env = _PROVIDER_KEY_ENV[embed_transport]
|
||||
key = _prompt_secret(f"{embed_transport} embedding API key", env.get(key_env))
|
||||
return embed_transport, key
|
||||
|
||||
|
||||
def _choose(label: str, options: list[tuple[str, str]], default: str) -> str:
|
||||
ids = [item[0] for item in options]
|
||||
default_idx = ids.index(default) + 1 if default in ids else 1
|
||||
_console.print(f" [dim]{label}[/dim]")
|
||||
for i, (_oid, desc) in enumerate(options, 1):
|
||||
_console.print(f" [dim]({i})[/dim] {desc}")
|
||||
raw = typer.prompt(
|
||||
" Choice",
|
||||
default=str(default_idx),
|
||||
show_default=True,
|
||||
prompt_suffix=": ",
|
||||
).strip()
|
||||
try:
|
||||
idx = int(raw)
|
||||
except ValueError:
|
||||
if raw in ids:
|
||||
return raw
|
||||
return options[default_idx - 1][0]
|
||||
if 1 <= idx <= len(options):
|
||||
return options[idx - 1][0]
|
||||
return options[default_idx - 1][0]
|
||||
|
||||
|
||||
def _choose_bool(label: str, default: bool) -> bool:
|
||||
return (
|
||||
_choose(label, [("true", "On"), ("false", "Off")], "true" if default else "false")
|
||||
== "true"
|
||||
)
|
||||
|
||||
|
||||
def _prompt_text(label: str, default: str, *, required: bool = False) -> str:
|
||||
while True:
|
||||
raw = typer.prompt(
|
||||
f" {label}",
|
||||
default=default,
|
||||
show_default=bool(default),
|
||||
prompt_suffix=": ",
|
||||
).strip()
|
||||
value = raw or default
|
||||
if value or not required:
|
||||
return value
|
||||
_console.print(" [red]A model name is required[/red]")
|
||||
|
||||
|
||||
def _prompt_int(label: str, default: int) -> int:
|
||||
while True:
|
||||
raw = typer.prompt(
|
||||
f" {label}",
|
||||
default=str(default),
|
||||
show_default=True,
|
||||
prompt_suffix=": ",
|
||||
).strip()
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
_console.print(" [red]Enter an integer[/red]")
|
||||
continue
|
||||
if value > 0:
|
||||
return value
|
||||
_console.print(" [red]Must be a positive integer[/red]")
|
||||
|
||||
|
||||
def _prompt_secret(label: str, current: str | None) -> str:
|
||||
if current and not is_placeholder_key(current):
|
||||
_console.print(f" [dim]Current {label}: {_redact(current)}[/dim]")
|
||||
_console.print(" [dim](1)[/dim] Keep current key")
|
||||
_console.print(" [dim](2)[/dim] Enter a new key")
|
||||
choice = typer.prompt(
|
||||
" Choice", default="1", show_default=True, prompt_suffix=": "
|
||||
).strip()
|
||||
if choice != "2":
|
||||
return current
|
||||
_console.print(f" [dim]{label}[/dim]")
|
||||
raw = _prompt_masked(f" {label}: ").strip()
|
||||
if not raw or is_placeholder_key(raw):
|
||||
print_error(
|
||||
"MISSING_LLM_KEY",
|
||||
f"{label} is required.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
return raw
|
||||
|
||||
|
||||
def _prompt_masked(prompt: str) -> str:
|
||||
"""Read a secret, echoing ``*`` per character so paste is visibly received."""
|
||||
stream = sys.stderr
|
||||
stream.write(prompt)
|
||||
stream.flush()
|
||||
chars: list[str] = []
|
||||
|
||||
def _write(text: str) -> None:
|
||||
stream.write(text)
|
||||
stream.flush()
|
||||
|
||||
def _feed(ch: str) -> bool:
|
||||
"""Return True when input is complete."""
|
||||
if not ch or ch in ("\n", "\r", "\x04"):
|
||||
_write("\n")
|
||||
return True
|
||||
if ch in ("\x7f", "\x08"):
|
||||
if chars:
|
||||
chars.pop()
|
||||
_write("\b \b")
|
||||
return False
|
||||
if ch == "\x1b":
|
||||
return False
|
||||
if ch.isprintable():
|
||||
chars.append(ch)
|
||||
_write("*")
|
||||
return False
|
||||
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
while True:
|
||||
ch = msvcrt.getwch()
|
||||
if ch in ("\x00", "\xe0"):
|
||||
msvcrt.getwch()
|
||||
continue
|
||||
if _feed(ch):
|
||||
return "".join(chars)
|
||||
|
||||
import termios
|
||||
import tty
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setcbreak(fd)
|
||||
while True:
|
||||
ch = sys.stdin.read(1)
|
||||
if ch == "\x1b":
|
||||
nxt = sys.stdin.read(1)
|
||||
if nxt == "[":
|
||||
while True:
|
||||
seq = sys.stdin.read(1)
|
||||
if not seq or "@" <= seq <= "~":
|
||||
break
|
||||
continue
|
||||
if _feed(ch):
|
||||
return "".join(chars)
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def _redact(key: str) -> str:
|
||||
if len(key) <= 4:
|
||||
return "***"
|
||||
return "***" + key[-4:]
|
||||
|
||||
|
||||
def _env_bool(value: str | None, *, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Package data for the local stack (Compose template + Postgres init)."""
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
# Managed by `honcho start`. Re-rendered on every start — edit .env and config.toml, not this file.
|
||||
#
|
||||
# Images: ghcr.io/plastic-labs/honcho (API + deriver), pgvector/pgvector:pg15, redis:8.2
|
||||
# Ports bind to 127.0.0.1. Auth is off (AUTH_USE_AUTH=false in .env).
|
||||
|
||||
services:
|
||||
api:
|
||||
image: ${HONCHO_IMAGE:-ghcr.io/plastic-labs/honcho:latest}
|
||||
entrypoint: ["sh", "docker/entrypoint.sh"]
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:${API_PORT:-8000}:8000"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"/app/.venv/bin/python",
|
||||
"-c",
|
||||
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2).read()",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
volumes:
|
||||
- lancedb-data:/app/lancedb_data
|
||||
- ./config.toml:/app/config.toml:ro
|
||||
environment:
|
||||
- DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
|
||||
- CACHE_URL=redis://redis:6379/0?suppress=true
|
||||
- CACHE_ENABLED=true
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
|
||||
deriver:
|
||||
image: ${HONCHO_IMAGE:-ghcr.io/plastic-labs/honcho:latest}
|
||||
entrypoint: ["/app/.venv/bin/python", "-m", "src.deriver"]
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
database:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- lancedb-data:/app/lancedb_data
|
||||
- ./config.toml:/app/config.toml:ro
|
||||
environment:
|
||||
- DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
|
||||
- CACHE_URL=redis://redis:6379/0?suppress=true
|
||||
- CACHE_ENABLED=true
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
|
||||
database:
|
||||
image: pgvector/pgvector:pg15
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${DB_PORT:-5432}:5432"
|
||||
command: ["postgres", "-c", "max_connections=200"]
|
||||
environment:
|
||||
- POSTGRES_DB=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
- pgdata:/var/lib/postgresql/data/
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:8.2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "redis-cli ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
redis-data:
|
||||
lancedb-data:
|
||||
|
|
@ -0,0 +1 @@
|
|||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
|
@ -15,6 +15,7 @@ from honcho_cli import __version__
|
|||
from honcho_cli._help import HonchoTyperGroup, print_welcome
|
||||
from honcho_cli.branding import BANNER
|
||||
from honcho_cli.output import set_json_mode
|
||||
from honcho_cli.update_check import maybe_print_update_nag
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
|
|
@ -60,13 +61,18 @@ def main(
|
|||
if ctx.invoked_subcommand is None:
|
||||
print_welcome(Console())
|
||||
raise typer.Exit()
|
||||
maybe_print_update_nag()
|
||||
|
||||
|
||||
# Register top-level commands
|
||||
from honcho_cli.commands.setup import doctor, init
|
||||
from honcho_cli.commands.stack import start, status, stop
|
||||
|
||||
app.command()(init)
|
||||
app.command()(doctor)
|
||||
app.command()(start)
|
||||
app.command()(stop)
|
||||
app.command()(status)
|
||||
|
||||
|
||||
@app.command("help", hidden=True)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ from rich.console import Console
|
|||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from honcho_cli.branding import ICON_FAIL, ICON_OK, ICON_RUN
|
||||
|
||||
console = Console(stderr=True)
|
||||
stdout_console = Console()
|
||||
|
||||
|
|
@ -106,6 +108,24 @@ def status(msg: str) -> None:
|
|||
console.print(f"[dim]{msg}[/dim]")
|
||||
|
||||
|
||||
def step(msg: str) -> None:
|
||||
"""Print a progress step. No-op in JSON mode."""
|
||||
if not use_json():
|
||||
console.print(f" {ICON_RUN} {msg}")
|
||||
|
||||
|
||||
def ok(msg: str) -> None:
|
||||
"""Print a success line. No-op in JSON mode."""
|
||||
if not use_json():
|
||||
console.print(f" {ICON_OK} {msg}")
|
||||
|
||||
|
||||
def fail(msg: str) -> None:
|
||||
"""Print a failure line. No-op in JSON mode."""
|
||||
if not use_json():
|
||||
console.print(f" {ICON_FAIL} {msg}")
|
||||
|
||||
|
||||
# Stable peer-color palette for transcript rendering. Brand blue first so the
|
||||
# primary peer lands on brand when there's only one speaker.
|
||||
_PEER_COLORS = (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
"""Once-a-day stderr notice when a newer honcho-cli is on PyPI.
|
||||
|
||||
Fail-open: any error is swallowed. Cache is ``update-check.json`` beside
|
||||
config, not ``config.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from honcho_cli import __version__
|
||||
from honcho_cli.branding import ICON_RUN
|
||||
from honcho_cli.config import _config_dir
|
||||
from honcho_cli.output import console, use_json
|
||||
|
||||
_INTERVAL_S = 24 * 60 * 60
|
||||
_PYPI_URL = "https://pypi.org/pypi/honcho-cli/json"
|
||||
|
||||
|
||||
def maybe_print_update_nag() -> None:
|
||||
if use_json() or "--json" in sys.argv:
|
||||
return
|
||||
if os.environ.get("HONCHO_NO_UPDATE_CHECK", "").lower() in ("1", "true"):
|
||||
return
|
||||
try:
|
||||
path = _config_dir() / "update-check.json"
|
||||
now = time.time()
|
||||
try:
|
||||
cache = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
cache = {}
|
||||
if isinstance(cache, dict) and now - float(cache.get("t") or 0) < _INTERVAL_S:
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"t": now}), encoding="utf-8")
|
||||
latest = httpx.get(_PYPI_URL, timeout=1.0).json()["info"]["version"]
|
||||
if not isinstance(latest, str) or not _is_newer(latest, __version__):
|
||||
return
|
||||
console.print(f" {ICON_RUN} honcho-cli {latest} is available (you have {__version__})")
|
||||
console.print(" [dim]uv tool upgrade honcho-cli[/dim]")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _is_newer(latest: str, current: str) -> bool:
|
||||
def parts(version: str) -> tuple[int, ...]:
|
||||
out: list[int] = []
|
||||
for segment in version.lstrip("v").split("."):
|
||||
num = ""
|
||||
for ch in segment:
|
||||
if ch.isdigit():
|
||||
num += ch
|
||||
else:
|
||||
break
|
||||
if not num:
|
||||
break
|
||||
out.append(int(num))
|
||||
return tuple(out) or (0,)
|
||||
|
||||
a, b = parts(latest), parts(current)
|
||||
n = max(len(a), len(b))
|
||||
return a + (0,) * (n - len(a)) > b + (0,) * (n - len(b))
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
"""Local-stack contracts: profile files, env merge, image pin, port remap."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from honcho_cli.local.docker import (
|
||||
DockerError,
|
||||
allocate_host_ports,
|
||||
pin_image,
|
||||
seed_config_toml,
|
||||
)
|
||||
from honcho_cli.local.env import managed_env, read_env_value, render_stack, upsert_env
|
||||
from honcho_cli.local.profile import LocalProfile, load_profile, save_profile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
|
||||
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", tmp_path / "config.json")
|
||||
for k in [k for k in os.environ if k.startswith("HONCHO_")]:
|
||||
monkeypatch.delenv(k)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_profile_roundtrip_has_no_secrets(cfg_dir):
|
||||
profile = LocalProfile(
|
||||
name="local",
|
||||
api_port=8001,
|
||||
image="ghcr.io/plastic-labs/honcho@sha256:abc",
|
||||
)
|
||||
save_profile(profile)
|
||||
loaded = load_profile("local")
|
||||
assert loaded.api_port == 8001
|
||||
assert loaded.image.endswith("@sha256:abc")
|
||||
on_disk = json.loads(profile.profile_file().read_text())
|
||||
assert "LLM" not in json.dumps(on_disk)
|
||||
assert set(on_disk) == {"apiPort", "dbPort", "redisPort", "image"}
|
||||
|
||||
|
||||
def test_upsert_preserves_extra_env_keys(tmp_path):
|
||||
path = tmp_path / ".env"
|
||||
path.write_text("CUSTOM_FLAG=keep-me\n# user comment\n")
|
||||
upsert_env(path, managed_env(LocalProfile(name="local")))
|
||||
text = path.read_text()
|
||||
assert "CUSTOM_FLAG=keep-me" in text
|
||||
assert "user comment" in text
|
||||
assert text.count("Generated by honcho start") == 1
|
||||
|
||||
|
||||
def test_upsert_writes_non_managed_and_preserves_later(tmp_path):
|
||||
path = tmp_path / ".env"
|
||||
first = managed_env(LocalProfile(name="local"))
|
||||
first["DERIVER_MODEL_CONFIG__MODEL"] = "gpt-test"
|
||||
upsert_env(path, first)
|
||||
upsert_env(path, managed_env(LocalProfile(name="local")))
|
||||
later = path.read_text()
|
||||
assert "DERIVER_MODEL_CONFIG__MODEL=gpt-test" in later
|
||||
|
||||
|
||||
def test_render_stack_uses_published_image(cfg_dir):
|
||||
profile = LocalProfile(name="local")
|
||||
render_stack(profile)
|
||||
compose = profile.compose_file().read_text()
|
||||
assert "ghcr.io/plastic-labs/honcho" in compose
|
||||
assert "build:" not in compose
|
||||
assert compose.count("./config.toml:/app/config.toml:ro") == 2
|
||||
assert read_env_value(profile.env_file(), "AUTH_USE_AUTH") == "false"
|
||||
assert oct(profile.env_file().stat().st_mode)[-3:] == "600"
|
||||
|
||||
|
||||
def test_pin_latest_to_matching_digest(monkeypatch):
|
||||
pulls: list[str] = []
|
||||
|
||||
def fake_run(args, *, check=False):
|
||||
if args[:1] == ["pull"]:
|
||||
pulls.append(args[1])
|
||||
return subprocess.CompletedProcess(args, 0, stdout="", stderr="")
|
||||
if args[:2] == ["image", "inspect"]:
|
||||
body = json.dumps(
|
||||
[
|
||||
"ghcr.io/plastic-labs/honcho@sha256:deadbeef",
|
||||
"ghcr.io/other/honcho@sha256:nope",
|
||||
]
|
||||
)
|
||||
return subprocess.CompletedProcess(args, 0, stdout=body, stderr="")
|
||||
raise AssertionError(args)
|
||||
|
||||
monkeypatch.setattr("honcho_cli.local.docker._run_docker", fake_run)
|
||||
assert pin_image("ghcr.io/plastic-labs/honcho:latest") == (
|
||||
"ghcr.io/plastic-labs/honcho@sha256:deadbeef"
|
||||
)
|
||||
assert pulls == ["ghcr.io/plastic-labs/honcho:latest"]
|
||||
|
||||
|
||||
def test_seed_config_toml_writes_once(cfg_dir, monkeypatch):
|
||||
profile = LocalProfile(
|
||||
name="local", image="ghcr.io/plastic-labs/honcho@sha256:abc"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"honcho_cli.local.docker._copy_from_image",
|
||||
lambda image, paths: "[deriver]\nWORKERS = 2\n",
|
||||
)
|
||||
assert seed_config_toml(profile) is True
|
||||
profile.config_file().write_text(
|
||||
profile.config_file().read_text() + "# user edit\n"
|
||||
)
|
||||
assert seed_config_toml(profile) is False
|
||||
assert "# user edit" in profile.config_file().read_text()
|
||||
|
||||
|
||||
def test_busy_port_remaps_unless_pinned(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"honcho_cli.local.docker.port_available",
|
||||
lambda port, host="127.0.0.1": port != 6379,
|
||||
)
|
||||
profile, remapped = allocate_host_ports(LocalProfile(name="local"))
|
||||
assert profile.redis_port == 6380
|
||||
assert remapped["redis"] == (6379, 6380)
|
||||
|
||||
with pytest.raises(DockerError) as exc:
|
||||
allocate_host_ports(LocalProfile(name="local"), pinned=frozenset({"redis"}))
|
||||
assert exc.value.code == "PORT_IN_USE"
|
||||
assert exc.value.details["flag"] == "--redis-port"
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
"""Wizard mapping: ``answers_to_env`` and image-toml defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from honcho_cli.local.setup import (
|
||||
DIALECTIC_LEVELS,
|
||||
SetupAnswers,
|
||||
answers_drop_keys,
|
||||
answers_to_env,
|
||||
chat_model_default,
|
||||
load_toml_setup_defaults,
|
||||
)
|
||||
|
||||
|
||||
def test_basic_openai_applies_chat_model_everywhere():
|
||||
env = answers_to_env(
|
||||
SetupAnswers(
|
||||
mode="basic",
|
||||
provider="openai",
|
||||
api_key="sk-test",
|
||||
chat_model="gpt-test",
|
||||
)
|
||||
)
|
||||
assert env["LLM_OPENAI_API_KEY"] == "sk-test"
|
||||
assert env["DERIVER_MODEL_CONFIG__MODEL"] == "gpt-test"
|
||||
assert env["SUMMARY_MODEL_CONFIG__MODEL"] == "gpt-test"
|
||||
for level in DIALECTIC_LEVELS:
|
||||
assert env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__MODEL"] == "gpt-test"
|
||||
assert "DREAM_ENABLED" not in env
|
||||
assert "EMBEDDING_MODEL_CONFIG__MODEL" not in env
|
||||
|
||||
|
||||
def test_basic_anthropic_keeps_openai_embeddings_default():
|
||||
env = answers_to_env(
|
||||
SetupAnswers(
|
||||
mode="basic",
|
||||
provider="anthropic",
|
||||
api_key="sk-ant",
|
||||
chat_model="claude-haiku-4-5",
|
||||
embedding_api_key="sk-embed",
|
||||
embedding_key_transport="openai",
|
||||
)
|
||||
)
|
||||
assert env["LLM_ANTHROPIC_API_KEY"] == "sk-ant"
|
||||
assert env["LLM_OPENAI_API_KEY"] == "sk-embed"
|
||||
assert env["DERIVER_MODEL_CONFIG__TRANSPORT"] == "anthropic"
|
||||
assert "EMBEDDING_MODEL_CONFIG__TRANSPORT" not in env
|
||||
|
||||
|
||||
def test_openai_compatible_copies_base_url_to_embeddings():
|
||||
env = answers_to_env(
|
||||
SetupAnswers(
|
||||
mode="basic",
|
||||
provider="openai-compatible",
|
||||
api_key="sk-or-test",
|
||||
chat_model="gpt-test",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
)
|
||||
)
|
||||
assert env["LLM_OPENAI_BASE_URL"] == "https://openrouter.ai/api/v1"
|
||||
assert (
|
||||
env["EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL"]
|
||||
== "https://openrouter.ai/api/v1"
|
||||
)
|
||||
|
||||
|
||||
def test_leaving_openai_compatible_drops_proxy_urls():
|
||||
dropped = answers_drop_keys(
|
||||
SetupAnswers(
|
||||
mode="basic", provider="openai", api_key="sk", chat_model="gpt-test"
|
||||
)
|
||||
)
|
||||
assert "LLM_OPENAI_BASE_URL" in dropped
|
||||
assert "EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL" in dropped
|
||||
|
||||
|
||||
def test_chat_default_comes_from_image_toml(tmp_path):
|
||||
path = tmp_path / "config.toml"
|
||||
path.write_text(
|
||||
"[deriver.model_config]\n"
|
||||
'transport = "openai"\n'
|
||||
'model = "gpt-from-image"\n'
|
||||
)
|
||||
defaults = load_toml_setup_defaults(path)
|
||||
assert defaults.chat_model == "gpt-from-image"
|
||||
assert chat_model_default("openai", {}, defaults) == "gpt-from-image"
|
||||
assert chat_model_default("openai-compatible", {}, defaults) == "gpt-from-image"
|
||||
assert chat_model_default("anthropic", {}, defaults) == ""
|
||||
assert chat_model_default(
|
||||
"openai",
|
||||
{"DERIVER_MODEL_CONFIG__MODEL": "gpt-from-env"},
|
||||
defaults,
|
||||
inferred="openai",
|
||||
) == "gpt-from-env"
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
"""CLI contracts for `honcho start` / `stop` / `status`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from honcho_cli.local.docker import image_is_digest, image_repository
|
||||
from honcho_cli.main import app
|
||||
from typer.testing import CliRunner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg(tmp_path, monkeypatch):
|
||||
f = tmp_path / "config.json"
|
||||
monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
|
||||
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f)
|
||||
monkeypatch.setattr("honcho_cli.commands.setup.CONFIG_FILE", f)
|
||||
for k in [k for k in os.environ if k.startswith(("HONCHO_", "LLM_"))]:
|
||||
monkeypatch.delenv(k)
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
return CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _host_ports_free(monkeypatch):
|
||||
monkeypatch.setattr("honcho_cli.local.docker.port_available", lambda *a, **k: True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_image_pin(monkeypatch):
|
||||
def fake_pin(image: str) -> str:
|
||||
if image_is_digest(image):
|
||||
return image
|
||||
return f"{image_repository(image)}@sha256:cafedeadbeef"
|
||||
|
||||
monkeypatch.setattr("honcho_cli.commands.stack.pin_image", fake_pin)
|
||||
monkeypatch.setattr("honcho_cli.commands.stack.seed_config_toml", lambda profile: False)
|
||||
|
||||
|
||||
_PS = [
|
||||
{"Service": "api", "State": "running", "Health": "healthy"},
|
||||
{"Service": "deriver", "State": "running"},
|
||||
{"Service": "database", "State": "running", "Health": "healthy"},
|
||||
{"Service": "redis", "State": "running", "Health": "healthy"},
|
||||
]
|
||||
|
||||
|
||||
def test_start_does_not_rewrite_environment_url(cfg, runner, monkeypatch):
|
||||
cfg.write_text(
|
||||
json.dumps({"apiKey": "k", "environmentUrl": "https://api.honcho.dev"})
|
||||
)
|
||||
monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: False)
|
||||
monkeypatch.setattr("honcho_cli.commands.stack.compose_up", lambda profile, **k: None)
|
||||
monkeypatch.setattr("honcho_cli.commands.stack.wait_for_health", lambda *a, **k: True)
|
||||
monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: _PS)
|
||||
monkeypatch.setenv("LLM_OPENAI_API_KEY", "sk-test")
|
||||
result = runner.invoke(app, ["start", "--json"])
|
||||
assert result.exit_code == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["endpoints"]["api"] == "http://127.0.0.1:8000"
|
||||
assert payload["image"].endswith("@sha256:cafedeadbeef")
|
||||
on_disk = json.loads(cfg.read_text())
|
||||
assert on_disk["environmentUrl"] == "https://api.honcho.dev"
|
||||
|
||||
|
||||
def test_start_requires_llm_key(cfg, runner, monkeypatch):
|
||||
monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: False)
|
||||
result = runner.invoke(app, ["start"])
|
||||
assert result.exit_code == 1
|
||||
assert json.loads(result.stderr)["error"]["code"] == "MISSING_LLM_KEY"
|
||||
|
||||
|
||||
def test_stop_already_stopped_skips_down(cfg, runner, tmp_path, monkeypatch):
|
||||
compose = tmp_path / "profiles" / "local" / "docker-compose.yml"
|
||||
compose.parent.mkdir(parents=True)
|
||||
compose.write_text("services: {}\n")
|
||||
monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: [])
|
||||
down = []
|
||||
monkeypatch.setattr(
|
||||
"honcho_cli.commands.stack.compose_down",
|
||||
lambda profile, wipe=False: down.append(wipe),
|
||||
)
|
||||
result = runner.invoke(app, ["stop"])
|
||||
assert result.exit_code == 0, result.stderr
|
||||
assert down == []
|
||||
assert json.loads(result.stdout)["status"] == "stopped"
|
||||
|
||||
|
||||
def test_status_lists_profiles_or_one(cfg, runner, tmp_path, monkeypatch):
|
||||
for name, port in (("demo", 8001), ("local", 8000)):
|
||||
d = tmp_path / "profiles" / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "docker-compose.yml").write_text("services: {}\n")
|
||||
(d / "profile.json").write_text(json.dumps({"apiPort": port}) + "\n")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"honcho_cli.commands.stack.compose_ps",
|
||||
lambda profile: _PS if profile.name == "local" else [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"honcho_cli.commands.stack.stack_healthy",
|
||||
lambda profile: profile.name == "local",
|
||||
)
|
||||
listed = runner.invoke(app, ["status"])
|
||||
assert listed.exit_code == 0, listed.stderr
|
||||
rows = json.loads(listed.stdout)["profiles"]
|
||||
by_name = {row["profile"]: row for row in rows}
|
||||
assert by_name["local"]["status"] == "running"
|
||||
assert by_name["demo"]["endpoints"]["api"] == "http://127.0.0.1:8001"
|
||||
|
||||
one = runner.invoke(app, ["status", "--profile", "local"])
|
||||
assert one.exit_code == 0, one.stderr
|
||||
payload = json.loads(one.stdout)
|
||||
assert payload["profile"] == "local"
|
||||
assert "profiles" not in payload
|
||||
|
||||
|
||||
def test_start_setup_requires_tty(cfg, runner):
|
||||
result = runner.invoke(app, ["start", "--setup", "basic", "--json"])
|
||||
assert result.exit_code == 1
|
||||
assert json.loads(result.stderr)["error"]["code"] == "SETUP_REQUIRES_TTY"
|
||||
|
||||
|
||||
def test_start_setup_recreates_when_already_running(cfg, runner, monkeypatch):
|
||||
from honcho_cli.local.setup import SetupAnswers
|
||||
|
||||
ups: list[tuple[str, ...]] = []
|
||||
monkeypatch.setattr("honcho_cli.commands.stack.use_json", lambda: False)
|
||||
monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: True)
|
||||
monkeypatch.setattr(
|
||||
"honcho_cli.commands.stack.compose_up",
|
||||
lambda profile, **k: ups.append(k.get("recreate", ())),
|
||||
)
|
||||
monkeypatch.setattr("honcho_cli.commands.stack.wait_for_health", lambda *a, **k: True)
|
||||
monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: _PS)
|
||||
monkeypatch.setattr(
|
||||
"honcho_cli.commands.stack.run_setup",
|
||||
lambda mode, path, config_path=None: SetupAnswers(
|
||||
mode="basic",
|
||||
provider="openai",
|
||||
api_key="sk-wiz",
|
||||
chat_model="gpt-test",
|
||||
),
|
||||
)
|
||||
pins: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"honcho_cli.commands.stack.pin_image",
|
||||
lambda image: pins.append(image) or image,
|
||||
)
|
||||
result = runner.invoke(app, ["start", "--setup", "basic"])
|
||||
assert result.exit_code == 0, result.stderr
|
||||
assert pins == []
|
||||
assert ups == [("api", "deriver")]
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "honcho"
|
||||
version = "3.0.12"
|
||||
version = "3.1.0"
|
||||
description = "Honcho Server"
|
||||
authors = [
|
||||
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
- `Session.last_message_at` exposes the newest message timestamp, and sync/async `Honcho.sessions()` accept `sort_by="created_at" | "last_message_at"` while preserving `reverse` across pagination. Requires a Honcho server with the matching API support.
|
||||
|
||||
## [2.4.0] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- Scopes: `Honcho.scope()` / `HonchoAio.scope()` get-or-create a named visibility boundary, `Honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `Honcho.session(..., scopes=[...])` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
|
||||
- `scope` option on `Peer.chat()` / `chat_stream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`.
|
||||
- Workspace-level chat: `Honcho.chat()` / `HonchoAio.chat()` and `chat_stream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoning_level`, and `response_format` options as `Peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
|
||||
|
||||
### Changed
|
||||
|
||||
- `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair.
|
||||
|
||||
## [2.3.0] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "honcho-ai"
|
||||
version = "2.3.0"
|
||||
version = "2.4.0"
|
||||
description = "Official DX Optimized Python SDK for Honcho"
|
||||
dynamic = ["readme"]
|
||||
license = "Apache-2.0"
|
||||
|
|
|
|||
|
|
@ -507,6 +507,79 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
"""Delete a workspace asynchronously."""
|
||||
await self._honcho._async_http_client.delete(routes.workspace(workspace_id))
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
async def chat(
|
||||
self,
|
||||
query: str = Field(..., min_length=1, description="The natural language query"),
|
||||
*,
|
||||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
scope: str | list[str] | None = None,
|
||||
) -> BaseModel | str | None:
|
||||
"""Query the entire workspace asynchronously (see Honcho.chat)."""
|
||||
await self._honcho._ensure_workspace_async()
|
||||
resolved_session_id = resolve_id(session)
|
||||
body: dict[str, Any] = {"query": query, "stream": False}
|
||||
if resolved_session_id:
|
||||
body["session_id"] = resolved_session_id
|
||||
if reasoning_level:
|
||||
body["reasoning_level"] = reasoning_level
|
||||
if scope is not None:
|
||||
body["scope"] = scope
|
||||
response_format_schema = serialize_response_format(response_format)
|
||||
if response_format_schema is not None:
|
||||
body["response_format"] = response_format_schema
|
||||
|
||||
data = await self._honcho._async_http_client.post(
|
||||
routes.workspace_chat(self._honcho.workspace_id),
|
||||
body=body,
|
||||
)
|
||||
content = data.get("content")
|
||||
if not content:
|
||||
return None
|
||||
if isinstance(response_format, type):
|
||||
return response_format.model_validate_json(content)
|
||||
return content
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
async def chat_stream(
|
||||
self,
|
||||
query: str = Field(..., min_length=1, description="The natural language query"),
|
||||
*,
|
||||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
scope: str | list[str] | None = None,
|
||||
) -> AsyncDialecticStreamResponse:
|
||||
"""Streaming variant of :meth:`chat` (async)."""
|
||||
await self._honcho._ensure_workspace_async()
|
||||
resolved_session_id = resolve_id(session)
|
||||
body: dict[str, Any] = {"query": query, "stream": True}
|
||||
if resolved_session_id:
|
||||
body["session_id"] = resolved_session_id
|
||||
if reasoning_level:
|
||||
body["reasoning_level"] = reasoning_level
|
||||
if scope is not None:
|
||||
body["scope"] = scope
|
||||
response_format_schema = serialize_response_format(response_format)
|
||||
if response_format_schema is not None:
|
||||
body["response_format"] = response_format_schema
|
||||
|
||||
async def stream_response() -> AsyncGenerator[str, None]:
|
||||
async for chunk in parse_sse_astream(
|
||||
self._honcho._async_http_client.stream(
|
||||
"POST",
|
||||
routes.workspace_chat(self._honcho.workspace_id),
|
||||
body=body,
|
||||
)
|
||||
):
|
||||
yield chunk
|
||||
|
||||
return AsyncDialecticStreamResponse(stream_response())
|
||||
|
||||
@validate_call
|
||||
async def search(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
|
|
@ -28,10 +28,16 @@ from .http import AsyncHonchoHTTPClient, HonchoHTTPClient, routes
|
|||
from .message import Message
|
||||
from .mixins import MetadataConfigMixin
|
||||
from .pagination import SyncPage
|
||||
from .peer import Peer
|
||||
from .peer import Peer, serialize_response_format
|
||||
from .scope import Scope
|
||||
from .session import Session
|
||||
from .utils import normalize_peers_to_dict, resolve_id, validate_scope_id
|
||||
from .types import DialecticStreamResponse
|
||||
from .utils import (
|
||||
normalize_peers_to_dict,
|
||||
parse_sse_stream,
|
||||
resolve_id,
|
||||
validate_scope_id,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -694,6 +700,98 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
"""
|
||||
self._http.delete(routes.workspace(workspace_id))
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
def chat(
|
||||
self,
|
||||
query: str = Field(..., min_length=1, description="The natural language query"),
|
||||
*,
|
||||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
scope: str | list[str] | None = None,
|
||||
) -> BaseModel | str | None:
|
||||
"""
|
||||
Query the entire workspace with a natural language question.
|
||||
|
||||
Unlike peer.chat(), which queries a single peer's representation, this
|
||||
searches across ALL peers and observations in the workspace — use it
|
||||
for cross-peer analysis, common themes, or workspace-wide questions.
|
||||
|
||||
Args:
|
||||
query: The natural language question to ask.
|
||||
session: Optional session to scope message retrieval to.
|
||||
reasoning_level: Optional reasoning level: "minimal", "low",
|
||||
"medium", "high", or "max" (default "low").
|
||||
response_format: Optional structure for the answer: a Pydantic
|
||||
model class (returns a parsed instance) or a raw
|
||||
JSON Schema dict (returns a JSON string).
|
||||
scope: Optional scope name(s) restricting recall to those scopes'
|
||||
member sessions. Mutually exclusive with `session`.
|
||||
|
||||
Returns:
|
||||
The synthesized answer, or None if no relevant information.
|
||||
"""
|
||||
self._ensure_workspace()
|
||||
resolved_session_id = resolve_id(session)
|
||||
body: dict[str, Any] = {"query": query, "stream": False}
|
||||
if resolved_session_id:
|
||||
body["session_id"] = resolved_session_id
|
||||
if reasoning_level:
|
||||
body["reasoning_level"] = reasoning_level
|
||||
if scope is not None:
|
||||
body["scope"] = scope
|
||||
response_format_schema = serialize_response_format(response_format)
|
||||
if response_format_schema is not None:
|
||||
body["response_format"] = response_format_schema
|
||||
|
||||
data = self._http.post(
|
||||
routes.workspace_chat(self.workspace_id),
|
||||
body=body,
|
||||
)
|
||||
content = data.get("content")
|
||||
if not content:
|
||||
return None
|
||||
if isinstance(response_format, type):
|
||||
return response_format.model_validate_json(content)
|
||||
return content
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
def chat_stream(
|
||||
self,
|
||||
query: str = Field(..., min_length=1, description="The natural language query"),
|
||||
*,
|
||||
session: str | SessionBase | None = None,
|
||||
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
|
||||
| None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
scope: str | list[str] | None = None,
|
||||
) -> DialecticStreamResponse:
|
||||
"""Streaming variant of :meth:`chat`. See chat() for argument docs."""
|
||||
self._ensure_workspace()
|
||||
resolved_session_id = resolve_id(session)
|
||||
body: dict[str, Any] = {"query": query, "stream": True}
|
||||
if resolved_session_id:
|
||||
body["session_id"] = resolved_session_id
|
||||
if reasoning_level:
|
||||
body["reasoning_level"] = reasoning_level
|
||||
if scope is not None:
|
||||
body["scope"] = scope
|
||||
response_format_schema = serialize_response_format(response_format)
|
||||
if response_format_schema is not None:
|
||||
body["response_format"] = response_format_schema
|
||||
|
||||
def stream_response() -> Generator[str, None, None]:
|
||||
yield from parse_sse_stream(
|
||||
self._http.stream(
|
||||
"POST",
|
||||
routes.workspace_chat(self.workspace_id),
|
||||
body=body,
|
||||
)
|
||||
)
|
||||
|
||||
return DialecticStreamResponse(stream_response())
|
||||
|
||||
@validate_call
|
||||
def search(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ def workspace(workspace_id: str) -> str:
|
|||
return f"/{API_VERSION}/workspaces/{workspace_id}"
|
||||
|
||||
|
||||
def workspace_chat(workspace_id: str) -> str:
|
||||
return f"/{API_VERSION}/workspaces/{workspace_id}/chat"
|
||||
|
||||
|
||||
def workspace_search(workspace_id: str) -> str:
|
||||
return f"/{API_VERSION}/workspaces/{workspace_id}/search"
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
- `Session.lastMessageAt` exposes the newest message timestamp, and `Honcho.sessions()` accepts `sortBy: 'created_at' | 'last_message_at'` while preserving `reverse` across pagination. Requires a Honcho server with the matching API support.
|
||||
|
||||
## [2.4.0] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- Scopes: `honcho.scope()` get-or-creates a named visibility boundary, `honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `honcho.session({ scopes: [...] })` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
|
||||
- `scope` option on `peer.chat()` / `chatStream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`.
|
||||
- Workspace-level chat: `honcho.chat()` / `honcho.chatStream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoningLevel`, and `responseFormat` options as `peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
|
||||
|
||||
### Changed
|
||||
|
||||
- `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair.
|
||||
|
||||
## [2.3.0] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@honcho-ai/sdk",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0",
|
||||
"description": "Official DX Optimized TypeScript SDK for Honcho",
|
||||
"author": "Plastic Labs <hello@plasticlabs.ai>",
|
||||
"license": "Apache-2.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { API_VERSION } from './api-version'
|
||||
import { HonchoHTTPClient } from './http/client'
|
||||
import {
|
||||
createDialecticStream,
|
||||
type DialecticStreamResponse,
|
||||
} from './http/streaming'
|
||||
import { Message } from './message'
|
||||
import { Page } from './pagination'
|
||||
import { Peer } from './peer'
|
||||
|
|
@ -14,6 +18,8 @@ import type {
|
|||
QueueStatusResponse,
|
||||
ScopeResponse,
|
||||
SessionResponse,
|
||||
WorkspaceChatParams,
|
||||
WorkspaceChatResponse,
|
||||
WorkspaceResponse,
|
||||
} from './types/api'
|
||||
import { resolveId, transformQueueStatus } from './utils'
|
||||
|
|
@ -53,6 +59,7 @@ import {
|
|||
} from './validation'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.honcho.dev'
|
||||
type ReasoningLevel = 'minimal' | 'low' | 'medium' | 'high' | 'max'
|
||||
|
||||
/**
|
||||
* Main client for the Honcho TypeScript SDK.
|
||||
|
|
@ -406,6 +413,34 @@ export class Honcho {
|
|||
)
|
||||
}
|
||||
|
||||
private async _workspaceChat(
|
||||
workspaceId: string,
|
||||
params: WorkspaceChatParams
|
||||
): Promise<WorkspaceChatResponse> {
|
||||
await this._ensureWorkspace()
|
||||
return this._http.post<WorkspaceChatResponse>(
|
||||
`/${API_VERSION}/workspaces/${workspaceId}/chat`,
|
||||
{ body: params }
|
||||
)
|
||||
}
|
||||
|
||||
private async _workspaceChatStream(
|
||||
workspaceId: string,
|
||||
params: Omit<WorkspaceChatParams, 'stream'>
|
||||
): Promise<Response> {
|
||||
await this._ensureWorkspace()
|
||||
return this._http.stream(
|
||||
'POST',
|
||||
`/${API_VERSION}/workspaces/${workspaceId}/chat`,
|
||||
{
|
||||
body: {
|
||||
...params,
|
||||
stream: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Public Methods
|
||||
// ===========================================================================
|
||||
|
|
@ -960,6 +995,106 @@ export class Honcho {
|
|||
return response.map(Message.fromApiResponse)
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the workspace's collective knowledge using natural language.
|
||||
*
|
||||
* Performs agentic search and reasoning across ALL peers and observations
|
||||
* in the workspace to synthesize a comprehensive answer. Useful for
|
||||
* cross-peer analysis, discovering common themes, and workspace-wide queries.
|
||||
*
|
||||
* @param query - The natural language question to ask
|
||||
* @param options.session - Optional session to scope message search to. Can be a session
|
||||
* ID string or a Session object.
|
||||
* @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low",
|
||||
* "medium", "high", or "max". Defaults to "low" if not provided.
|
||||
* @param options.responseFormat - Optional JSON Schema (root type "object") the response
|
||||
* must conform to. When provided, the response content is a
|
||||
* JSON string matching this schema.
|
||||
* @returns Promise resolving to the response string, or null if no relevant information
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const response = await honcho.chat('What are common themes across all users?')
|
||||
* ```
|
||||
*/
|
||||
async chat(
|
||||
query: string,
|
||||
options?: {
|
||||
session?: string | Session
|
||||
reasoningLevel?: ReasoningLevel
|
||||
responseFormat?: Record<string, unknown>
|
||||
scope?: string | string[]
|
||||
}
|
||||
): Promise<string | null> {
|
||||
const validatedQuery = SearchQuerySchema.parse(query)
|
||||
const resolvedSessionId = options?.session
|
||||
? resolveId(options.session)
|
||||
: undefined
|
||||
|
||||
const response = await this._workspaceChat(this.workspaceId, {
|
||||
query: validatedQuery,
|
||||
stream: false,
|
||||
session_id: resolvedSessionId,
|
||||
reasoning_level: options?.reasoningLevel,
|
||||
response_format: options?.responseFormat,
|
||||
scope: options?.scope,
|
||||
})
|
||||
if (!response.content) {
|
||||
return null
|
||||
}
|
||||
return response.content
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the workspace's collective knowledge with streaming response.
|
||||
*
|
||||
* Performs agentic search and reasoning across ALL peers and observations
|
||||
* in the workspace to synthesize a comprehensive answer, streaming the
|
||||
* response as it is generated.
|
||||
*
|
||||
* @param query - The natural language question to ask
|
||||
* @param options.session - Optional session to scope message search to. Can be a session
|
||||
* ID string or a Session object.
|
||||
* @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low",
|
||||
* "medium", "high", or "max". Defaults to "low" if not provided.
|
||||
* @param options.responseFormat - Optional JSON Schema (root type "object") the response
|
||||
* must conform to. When provided, the response content is a
|
||||
* JSON string matching this schema.
|
||||
* @returns Promise resolving to a DialecticStreamResponse that can be iterated over
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const stream = await honcho.chatStream('What do all peers have in common?')
|
||||
* for await (const chunk of stream) {
|
||||
* process.stdout.write(chunk)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
async chatStream(
|
||||
query: string,
|
||||
options?: {
|
||||
session?: string | Session
|
||||
reasoningLevel?: ReasoningLevel
|
||||
responseFormat?: Record<string, unknown>
|
||||
scope?: string | string[]
|
||||
}
|
||||
): Promise<DialecticStreamResponse> {
|
||||
const validatedQuery = SearchQuerySchema.parse(query)
|
||||
const resolvedSessionId = options?.session
|
||||
? resolveId(options.session)
|
||||
: undefined
|
||||
|
||||
const response = await this._workspaceChatStream(this.workspaceId, {
|
||||
query: validatedQuery,
|
||||
session_id: resolvedSessionId,
|
||||
reasoning_level: options?.reasoningLevel,
|
||||
response_format: options?.responseFormat,
|
||||
scope: options?.scope,
|
||||
})
|
||||
|
||||
return createDialecticStream(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the queue processing status, optionally scoped to an observer, sender, and/or session.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export type {
|
|||
SessionResponse,
|
||||
SessionSummariesResponse,
|
||||
SummaryResponse,
|
||||
WorkspaceChatResponse,
|
||||
WorkspaceResponse,
|
||||
} from './types/api'
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,19 @@ export interface PeerChatResponse {
|
|||
content: string | null
|
||||
}
|
||||
|
||||
export interface WorkspaceChatParams {
|
||||
query: string
|
||||
stream?: boolean
|
||||
session_id?: string
|
||||
reasoning_level?: 'minimal' | 'low' | 'medium' | 'high' | 'max'
|
||||
response_format?: Record<string, unknown>
|
||||
scope?: string | string[]
|
||||
}
|
||||
|
||||
export interface WorkspaceChatResponse {
|
||||
content: string | null
|
||||
}
|
||||
|
||||
export interface PeerRepresentationParams {
|
||||
session_id?: string
|
||||
target?: string
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep
|
|||
## Command groups
|
||||
|
||||
- `honcho config` — CLI configuration
|
||||
- `honcho start` / `stop` / `status` — local Docker stack (does not change `environmentUrl`). First start pins the Honcho image digest and writes `config.toml` into the profile. Pass `--setup basic` or `--setup advanced` for an interactive config wizard (TTY only; writes `.env` overrides). `honcho status` lists every profile; pass `--profile` for one.
|
||||
- `honcho workspace` — inspect, delete, search
|
||||
- `honcho peer` — inspect, card, chat, search
|
||||
- `honcho session` — inspect, view (transcript), context, summaries
|
||||
|
|
@ -31,6 +32,7 @@ allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep
|
|||
- Use `honcho session context` to see exactly what an agent receives.
|
||||
- Never run `honcho workspace delete` without `honcho workspace inspect` first.
|
||||
- Compare peer card with conclusions to understand memory state.
|
||||
- `honcho start` does not rewrite `environmentUrl`. Use `HONCHO_BASE_URL=http://127.0.0.1:8000` to talk to local stack.
|
||||
|
||||
## Inspection tour
|
||||
|
||||
|
|
|
|||
|
|
@ -123,6 +123,28 @@ def get_cache_namespace() -> str:
|
|||
return cast(str, settings.CACHE.NAMESPACE)
|
||||
|
||||
|
||||
# On Redis Cluster a key's slot is derived from the substring inside the first
|
||||
# {...}, when one is present. Tagging the namespace puts every key an instance
|
||||
# writes on a single slot, and therefore a single shard, so its client holds
|
||||
# connections to one node rather than to all of them. Namespaces still hash
|
||||
# independently of one another, so keys stay spread across the cluster.
|
||||
#
|
||||
# Two spellings, because the two ways a key gets built treat the string
|
||||
# differently: cashews runs `prefix=` through format substitution, so braces
|
||||
# have to be doubled to survive as literals, while direct construction does no
|
||||
# substitution and needs them single. Both render to the same bytes, which
|
||||
# tests/cache/test_cache_namespace_hash_tag.py asserts -- a mismatch would send
|
||||
# writes and deletes to different keys with nothing raised.
|
||||
def cache_key_namespace() -> str:
|
||||
"""Tagged namespace for keys built by string concatenation."""
|
||||
return "{" + get_cache_namespace() + "}"
|
||||
|
||||
|
||||
def cache_prefix_namespace() -> str:
|
||||
"""Tagged namespace for cashews `prefix=`, which format-substitutes."""
|
||||
return "{{" + get_cache_namespace() + "}}"
|
||||
|
||||
|
||||
async def init_cache() -> None:
|
||||
"""Initialize and verify cache connection if enabled."""
|
||||
async with _cache_lock:
|
||||
|
|
@ -256,6 +278,8 @@ __all__ = [
|
|||
"init_cache",
|
||||
"close_cache",
|
||||
"cache",
|
||||
"cache_key_namespace",
|
||||
"cache_prefix_namespace",
|
||||
"safe_cache_delete",
|
||||
"safe_cache_set",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ from .scope import (
|
|||
invalidate_scope_peer_cache,
|
||||
remove_session_from_scope,
|
||||
resolve_scope_peers,
|
||||
resolve_scope_session_union,
|
||||
update_scope_backfill_status,
|
||||
)
|
||||
from .session import (
|
||||
|
|
@ -81,16 +82,24 @@ from .webhook import (
|
|||
list_webhook_endpoints,
|
||||
)
|
||||
from .workspace import (
|
||||
ActivePeer,
|
||||
WorkspaceDeletionResult,
|
||||
WorkspaceStats,
|
||||
check_no_active_sessions,
|
||||
delete_workspace,
|
||||
get_active_peers,
|
||||
get_all_workspaces,
|
||||
get_or_create_workspace,
|
||||
get_workspace,
|
||||
get_workspace_stats,
|
||||
update_workspace,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_workspace_stats",
|
||||
"get_active_peers",
|
||||
"WorkspaceStats",
|
||||
"ActivePeer",
|
||||
# Collection
|
||||
"get_collection",
|
||||
"get_or_create_collection",
|
||||
|
|
@ -150,6 +159,7 @@ __all__ = [
|
|||
"invalidate_scope_peer_cache",
|
||||
"remove_session_from_scope",
|
||||
"resolve_scope_peers",
|
||||
"resolve_scope_session_union",
|
||||
"update_scope_backfill_status",
|
||||
# Session
|
||||
"SessionDeletionResult",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ from sqlalchemy.orm import make_transient_to_detached
|
|||
from src import models
|
||||
from src.cache.client import (
|
||||
cache,
|
||||
get_cache_namespace,
|
||||
cache_key_namespace,
|
||||
cache_prefix_namespace,
|
||||
safe_cache_delete,
|
||||
safe_cache_set,
|
||||
)
|
||||
|
|
@ -22,13 +23,13 @@ logger = getLogger(__name__)
|
|||
COLLECTION_CACHE_KEY_TEMPLATE = (
|
||||
"v2:workspace:{workspace_name}:collection:{observer}:{observed}"
|
||||
)
|
||||
COLLECTION_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2"
|
||||
COLLECTION_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2"
|
||||
|
||||
|
||||
def collection_cache_key(workspace_name: str, observer: str, observed: str) -> str:
|
||||
"""Generate cache key for collection."""
|
||||
return (
|
||||
get_cache_namespace()
|
||||
cache_key_namespace()
|
||||
+ ":"
|
||||
+ COLLECTION_CACHE_KEY_TEMPLATE.format(
|
||||
workspace_name=workspace_name,
|
||||
|
|
@ -41,7 +42,7 @@ def collection_cache_key(workspace_name: str, observer: str, observed: str) -> s
|
|||
@cache(
|
||||
key=COLLECTION_CACHE_KEY_TEMPLATE,
|
||||
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
|
||||
prefix=get_cache_namespace(),
|
||||
prefix=cache_prefix_namespace(),
|
||||
condition=NOT_NONE,
|
||||
)
|
||||
@cache.locked(
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from src.crud.collection import get_or_create_collection
|
|||
from src.crud.peer import get_peer, reject_scope_observed
|
||||
from src.crud.session import get_session
|
||||
from src.dependencies import tracked_db
|
||||
from src.embedding_client import embedding_client
|
||||
from src.embedding_client import EmbeddingTokenLimitError, embedding_client
|
||||
from src.exceptions import (
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
|
|
@ -231,6 +231,9 @@ async def query_external_vector_document_ids(
|
|||
if _uses_pgvector():
|
||||
return None
|
||||
|
||||
if top_k <= 0:
|
||||
return []
|
||||
|
||||
external_vector_store = get_external_vector_store()
|
||||
if external_vector_store is None:
|
||||
return []
|
||||
|
|
@ -362,7 +365,7 @@ async def query_documents(
|
|||
if embedding is None:
|
||||
try:
|
||||
embedding = await embedding_client.embed(query)
|
||||
except ValueError as e:
|
||||
except EmbeddingTokenLimitError as e:
|
||||
raise ValidationException(
|
||||
"Query exceeds maximum token limit of "
|
||||
+ f"{settings.EMBEDDING.MAX_INPUT_TOKENS}."
|
||||
|
|
@ -987,7 +990,7 @@ async def create_observations(
|
|||
embeddings = await embedding_client.simple_batch_embed(
|
||||
contents, on_oversize="truncate"
|
||||
)
|
||||
except ValueError as e:
|
||||
except EmbeddingTokenLimitError as e:
|
||||
raise ValidationException(str(e)) from e
|
||||
|
||||
# Create document objects and track embeddings for vector store
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from sqlalchemy import (
|
|||
update,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import InstrumentedAttribute
|
||||
|
||||
from src import models, schemas
|
||||
from src.cache.client import safe_cache_delete
|
||||
|
|
@ -170,6 +171,91 @@ async def resolve_session_scope(
|
|||
return (allowed, False) if allowed else (None, True)
|
||||
|
||||
|
||||
def observer_scope_clause(
|
||||
workspace_name: str,
|
||||
observer: str,
|
||||
session_column: InstrumentedAttribute[str],
|
||||
) -> ColumnElement[bool]:
|
||||
"""Correlated EXISTS restricting ``session_column`` to the observer's sessions.
|
||||
|
||||
The in-database equivalent of filtering on :func:`get_peer_session_names`.
|
||||
Prefer it whenever the scope feeds a single SQL statement: a peer's
|
||||
membership count is unbounded, and materializing the names turns each one
|
||||
into its own bind parameter. The PostgreSQL wire protocol caps parameters
|
||||
at 65535 per statement, so a peer in enough sessions produces a query the
|
||||
driver cannot serialize at all — and the resulting error carries every
|
||||
parameter in its text.
|
||||
|
||||
Matches the loose membership definition ``get_peer_session_names`` uses by
|
||||
default: any membership record grants visibility, whether or not the peer
|
||||
has since left the session.
|
||||
"""
|
||||
session_peers = models.session_peers_table
|
||||
return (
|
||||
select(1)
|
||||
.where(session_peers.c.workspace_name == workspace_name)
|
||||
.where(session_peers.c.peer_name == observer)
|
||||
.where(session_peers.c.session_name == session_column)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
def resolve_session_scope_clauses(
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
session_allowlist: list[str] | None,
|
||||
observer: str | None,
|
||||
session_column: InstrumentedAttribute[str],
|
||||
) -> tuple[list[ColumnElement[bool]], bool]:
|
||||
"""SQL-side counterpart to :func:`resolve_session_scope`.
|
||||
|
||||
Returns ``(clauses, deny)``, where ``clauses`` are ANDed onto the caller's
|
||||
statement and ``deny=True`` means return an empty result without querying.
|
||||
Unlike :func:`resolve_session_scope` this touches no database and grows no
|
||||
bind parameters with the observer's session count — the observer half
|
||||
becomes a correlated EXISTS instead of an ``IN`` over fetched names.
|
||||
|
||||
Scoping matches :func:`resolve_session_scope` case for case, with one
|
||||
deliberate difference: where that function returns ``deny=True`` because an
|
||||
observer's membership (or its intersection with the allowlist) is empty,
|
||||
this returns clauses that simply match no rows. Callers reach the same empty
|
||||
result, at the cost of running one indexed query that returns nothing.
|
||||
|
||||
``session_allowlist`` stays an ``IN`` clause: it is caller-supplied and
|
||||
therefore bounded, so it carries none of the unbounded-growth risk.
|
||||
|
||||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
session_name: A single pinned session, if the caller named one. The
|
||||
caller applies its own equality filter; this function only checks
|
||||
the allowlist permits it.
|
||||
session_allowlist: Optional session allowlist. ``None`` is
|
||||
unrestricted; an empty list fails closed.
|
||||
observer: When set, scope is limited to this peer's sessions
|
||||
session_column: The session-name column to scope, e.g.
|
||||
``models.Message.session_name``
|
||||
"""
|
||||
if session_name:
|
||||
# Fail closed when the allowlist forbids the pinned session, matching
|
||||
# `resolve_session_scope` — routes guard this too, but the dialectic
|
||||
# tools reach CRUD directly, so enforce it at the boundary.
|
||||
if session_allowlist is not None and session_name not in session_allowlist:
|
||||
return [], True
|
||||
return [], False
|
||||
|
||||
clauses: list[ColumnElement[bool]] = []
|
||||
|
||||
if observer is not None:
|
||||
clauses.append(observer_scope_clause(workspace_name, observer, session_column))
|
||||
|
||||
if session_allowlist is not None:
|
||||
if not session_allowlist:
|
||||
return [], True
|
||||
clauses.append(session_column.in_(session_allowlist))
|
||||
|
||||
return clauses, False
|
||||
|
||||
|
||||
def _apply_token_limit(
|
||||
base_conditions: list[ColumnElement[Any]], token_limit: int
|
||||
) -> Select[tuple[models.Message]]:
|
||||
|
|
@ -695,6 +781,9 @@ async def _search_messages_external(
|
|||
Multiple vector records can map to the same message (chunked embeddings),
|
||||
so we oversample from the vector store and deduplicate by message_id.
|
||||
"""
|
||||
if limit <= 0:
|
||||
return []
|
||||
|
||||
external_vector_store = get_external_vector_store()
|
||||
if external_vector_store is None:
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from sqlalchemy.orm import make_transient_to_detached
|
||||
|
||||
from src import models, schemas
|
||||
from src.cache.client import cache, get_cache_namespace, safe_cache_delete
|
||||
from src.cache.client import (
|
||||
cache,
|
||||
cache_key_namespace,
|
||||
cache_prefix_namespace,
|
||||
safe_cache_delete,
|
||||
)
|
||||
from src.config import settings
|
||||
from src.crud.workspace import get_or_create_workspace
|
||||
from src.exceptions import (
|
||||
|
|
@ -32,13 +37,13 @@ logger = getLogger(__name__)
|
|||
PEER_NAME_MAX_LENGTH = 512
|
||||
|
||||
PEER_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}:peer:{peer_name}"
|
||||
PEER_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2"
|
||||
PEER_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2"
|
||||
|
||||
|
||||
def peer_cache_key(workspace_name: str, peer_name: str) -> str:
|
||||
"""Generate cache key for peer."""
|
||||
return (
|
||||
get_cache_namespace()
|
||||
cache_key_namespace()
|
||||
+ ":"
|
||||
+ PEER_CACHE_KEY_TEMPLATE.format(
|
||||
workspace_name=workspace_name,
|
||||
|
|
@ -391,7 +396,7 @@ async def get_or_create_peers(
|
|||
@cache(
|
||||
key=PEER_CACHE_KEY_TEMPLATE,
|
||||
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
|
||||
prefix=get_cache_namespace(),
|
||||
prefix=cache_prefix_namespace(),
|
||||
condition=NOT_NONE,
|
||||
)
|
||||
@cache.locked(
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from src import crud, exceptions, models, schemas
|
|||
from src.config import settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.dreamer.dream_scheduler import check_and_schedule_dream
|
||||
from src.embedding_client import embedding_client
|
||||
from src.embedding_client import EmbeddingTokenLimitError, embedding_client
|
||||
from src.schemas import ResolvedConfiguration
|
||||
from src.telemetry.events import EmbeddingCallPurpose
|
||||
from src.telemetry.logging import accumulate_metric
|
||||
|
|
@ -109,7 +109,7 @@ class RepresentationManager:
|
|||
embeddings = await embedding_client.simple_batch_embed(
|
||||
observation_texts, on_oversize="truncate"
|
||||
)
|
||||
except ValueError as e:
|
||||
except EmbeddingTokenLimitError as e:
|
||||
raise exceptions.ValidationException(
|
||||
"Observation content exceeds maximum token limit of "
|
||||
+ f"{settings.EMBEDDING.MAX_INPUT_TOKENS}."
|
||||
|
|
|
|||
|
|
@ -319,6 +319,26 @@ async def resolve_scope_peers(
|
|||
return resolved
|
||||
|
||||
|
||||
async def resolve_scope_session_union(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
scope_names: Sequence[str],
|
||||
) -> list[str]:
|
||||
"""Return the union of member sessions across the given scopes."""
|
||||
from src.crud.message import get_peer_session_names
|
||||
|
||||
union: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for scope_peer in await resolve_scope_peers(db, workspace_name, scope_names):
|
||||
for session_name in await get_peer_session_names(
|
||||
db, workspace_name, scope_peer
|
||||
):
|
||||
if session_name not in seen:
|
||||
seen.add(session_name)
|
||||
union.append(session_name)
|
||||
return union
|
||||
|
||||
|
||||
async def get_scope_sessions(
|
||||
workspace_name: str,
|
||||
scope_name: str,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ from sqlalchemy.types import BigInteger, Boolean
|
|||
from src import models, schemas
|
||||
from src.cache.client import (
|
||||
cache,
|
||||
get_cache_namespace,
|
||||
cache_key_namespace,
|
||||
cache_prefix_namespace,
|
||||
safe_cache_delete,
|
||||
safe_cache_set,
|
||||
)
|
||||
|
|
@ -67,13 +68,13 @@ class SessionDeletionResult:
|
|||
|
||||
|
||||
SESSION_CACHE_KEY_TEMPLATE = "v3:workspace:{workspace_name}:session:{session_name}"
|
||||
SESSION_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v3"
|
||||
SESSION_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v3"
|
||||
|
||||
|
||||
def session_cache_key(workspace_name: str, session_name: str) -> str:
|
||||
"""Generate cache key for session."""
|
||||
return (
|
||||
get_cache_namespace()
|
||||
cache_key_namespace()
|
||||
+ ":"
|
||||
+ SESSION_CACHE_KEY_TEMPLATE.format(
|
||||
workspace_name=workspace_name,
|
||||
|
|
@ -85,7 +86,7 @@ def session_cache_key(workspace_name: str, session_name: str) -> str:
|
|||
@cache(
|
||||
key=SESSION_CACHE_KEY_TEMPLATE,
|
||||
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
|
||||
prefix=get_cache_namespace(),
|
||||
prefix=cache_prefix_namespace(),
|
||||
condition=NOT_NONE,
|
||||
)
|
||||
@cache.locked(
|
||||
|
|
@ -1069,25 +1070,31 @@ async def set_peers_for_session(
|
|||
peer_names: dict[str, schemas.SessionPeerConfig],
|
||||
) -> list[models.SessionPeer]:
|
||||
"""
|
||||
Set peers for a session, overwriting any existing peers.
|
||||
If peers don't exist, they will be created.
|
||||
Replace a session's ordinary peer set with ``peer_names``.
|
||||
|
||||
Active members keep their joined_at but take the incoming configuration:
|
||||
this is a replace, so the caller's map is the desired end state. Departed
|
||||
members rejoin with the incoming configuration. Scope memberships are
|
||||
preserved.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
peer_names: Set of peer names to set for the session
|
||||
peer_names: Mapping of peer names to session-level configuration
|
||||
|
||||
Returns:
|
||||
List of SessionPeer objects for all peers in the session
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the session does not exist
|
||||
ObserverException: If the resulting peer set would exceed the observer
|
||||
limit
|
||||
"""
|
||||
# Validate observer limit before making any changes
|
||||
observer_count = count_observers_in_config(peer_names)
|
||||
if observer_count > settings.SESSION_OBSERVERS_LIMIT:
|
||||
raise ObserverException(session_name, observer_count)
|
||||
# No observer pre-check here: an already-active membership keeps its stored
|
||||
# configuration, so the incoming map is not what lands. Counting it would
|
||||
# reject a request that lowers the observer count as often as one that raises
|
||||
# it. _get_or_add_peers_to_session enforces the limit on the resulting rows.
|
||||
|
||||
# Verify session exists
|
||||
stmt = (
|
||||
|
|
@ -1103,20 +1110,21 @@ async def set_peers_for_session(
|
|||
f"Session {session_name} not found in workspace {workspace_name}"
|
||||
)
|
||||
|
||||
# Soft delete every *ordinary* active membership. Scope memberships are
|
||||
# deliberately preserved: this route replaces the peers the caller names, and a
|
||||
# caller detaches a scope by simply *omitting* it from an otherwise valid
|
||||
# replacement map — never naming it, so no request-level guard can see it.
|
||||
# Without the exclusion a plain replacement silently bypasses the facade that
|
||||
# owns scope membership and its removal reconciliation. Being part of the
|
||||
# UPDATE, this holds regardless of the request body or concurrent scope
|
||||
# creation.
|
||||
# Soft delete every *ordinary* active membership not in the incoming map.
|
||||
# Scope memberships are deliberately preserved: this route replaces the peers
|
||||
# the caller names, and a caller detaches a scope by simply *omitting* it from
|
||||
# an otherwise valid replacement map — never naming it, so no request-level
|
||||
# guard can see it. Without the exclusion a plain replacement silently
|
||||
# bypasses the facade that owns scope membership and its removal
|
||||
# reconciliation. Being part of the UPDATE, this holds regardless of the
|
||||
# request body or concurrent scope creation.
|
||||
update_stmt = (
|
||||
update(models.SessionPeer)
|
||||
.where(
|
||||
models.SessionPeer.session_name == session_name,
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
models.SessionPeer.left_at.is_(None), # Only update active peers
|
||||
models.SessionPeer.peer_name.notin_(peer_names.keys()),
|
||||
~exists(
|
||||
select(models.Peer.id)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
|
|
@ -1137,12 +1145,14 @@ async def set_peers_for_session(
|
|||
)
|
||||
_reject_resolved_scope_peers(peers_result.resource)
|
||||
|
||||
# Add new peers to session
|
||||
# Add new peers to session. This route replaces the session's peer set, so the
|
||||
# incoming configuration is authoritative even for an already-active member.
|
||||
peers = await _get_or_add_peers_to_session(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
peer_names=peer_names,
|
||||
replace_config=True,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
|
@ -1181,13 +1191,22 @@ async def _get_or_add_peers_to_session(
|
|||
peer_names: dict[str, schemas.SessionPeerConfig],
|
||||
*,
|
||||
fetch_after_upsert: bool = True,
|
||||
replace_config: bool = False,
|
||||
) -> list[models.SessionPeer]:
|
||||
"""
|
||||
Upsert session-peer memberships for a session and optionally fetch the
|
||||
active memberships afterward.
|
||||
|
||||
New peers are inserted, peers that previously left the session are rejoined,
|
||||
and already-active peers keep their existing session-level configuration.
|
||||
and already-active peers keep their existing joined_at.
|
||||
|
||||
An already-active peer also keeps its stored configuration unless
|
||||
``replace_config`` is set: an add must not overwrite configuration it was
|
||||
never asked about, while a replace states the desired end state.
|
||||
|
||||
The observer limit is checked against the rows the upsert actually produced,
|
||||
not against the incoming map, since under the add semantics the incoming map
|
||||
is not necessarily what lands.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
|
@ -1196,13 +1215,17 @@ async def _get_or_add_peers_to_session(
|
|||
peer_names: Mapping of peer names to session-level configuration
|
||||
fetch_after_upsert: If True, query and return the active session peers
|
||||
after the upsert. If False, skip that read and return an empty list.
|
||||
replace_config: If True, an already-active membership takes the incoming
|
||||
configuration instead of keeping its stored one. Set by replace-style
|
||||
callers; leave False for add-style callers.
|
||||
|
||||
Returns:
|
||||
Active SessionPeer objects after the upsert, or an empty list when the
|
||||
post-upsert fetch is skipped
|
||||
|
||||
Raises:
|
||||
ObserverException: If adding peers would exceed the observer limit
|
||||
ObserverException: If the resulting active peer set would exceed the
|
||||
observer limit
|
||||
"""
|
||||
# If no peers to add, skip the insert and just return existing active session peers
|
||||
if not peer_names:
|
||||
|
|
@ -1221,43 +1244,10 @@ async def _get_or_add_peers_to_session(
|
|||
# costs document rows, not LLM calls, and counting them would
|
||||
# cap scopes-per-session at SESSION_OBSERVERS_LIMIT and surface as an
|
||||
# observer-shaped 400 through a facade that hides observers entirely.
|
||||
# Resolved up front because the limit check below gates on whether this
|
||||
# request asks for a *non-scope* observer.
|
||||
scopes_being_added = await scope_peer_names(db, workspace_name, peer_names.keys())
|
||||
|
||||
# Only validate observer limit if we're adding non-scope peers with observe_others=True
|
||||
new_observer_count = count_observers_in_config(
|
||||
{n: c for n, c in peer_names.items() if n not in scopes_being_added}
|
||||
)
|
||||
|
||||
if new_observer_count > 0:
|
||||
# Use a single efficient query to count existing observers not being updated
|
||||
# This uses PostgreSQL's JSONB operators to check the observe_others field directly
|
||||
existing_observers_stmt = select(func.count()).where(
|
||||
models.SessionPeer.session_name == session_name,
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
models.SessionPeer.left_at.is_(None), # Only active peers
|
||||
models.SessionPeer.peer_name.notin_(
|
||||
peer_names.keys()
|
||||
), # Exclude peers being updated
|
||||
models.SessionPeer.configuration["observe_others"].astext.cast(
|
||||
Boolean
|
||||
), # Only observers
|
||||
# Existing scope memberships are excluded for the same reason as above.
|
||||
~exists(
|
||||
select(models.Peer.id)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name == models.SessionPeer.peer_name)
|
||||
.where(scope_peer_clause())
|
||||
.correlate(models.SessionPeer)
|
||||
),
|
||||
)
|
||||
result = await db.execute(existing_observers_stmt)
|
||||
existing_observer_count = result.scalar() or 0
|
||||
|
||||
total_observers = existing_observer_count + new_observer_count
|
||||
|
||||
if total_observers > settings.SESSION_OBSERVERS_LIMIT:
|
||||
raise ObserverException(session_name, total_observers)
|
||||
|
||||
# Use upsert to handle both new peers and rejoining peers
|
||||
stmt = pg_insert(models.SessionPeer).values(
|
||||
[
|
||||
|
|
@ -1273,15 +1263,27 @@ async def _get_or_add_peers_to_session(
|
|||
]
|
||||
)
|
||||
|
||||
# On conflict, update joined_at and clear left_at (rejoin scenario)
|
||||
# If left_at is not None (peer has left the session): Use the new configuration (stmt.excluded.configuration)
|
||||
# If left_at is None (peer is still active): Keep the existing configuration (models.SessionPeer.configuration)
|
||||
# On conflict, rejoin departed peers. joined_at always survives on an active
|
||||
# membership -- advancing it would move the peer_perspective search window
|
||||
# past messages the peer was present for (issue #940).
|
||||
#
|
||||
# Configuration depends on the caller's semantics. An add ("ensure this peer
|
||||
# is here") must not silently overwrite a config it never asked about, so an
|
||||
# active membership keeps its stored one. A replace ("these are the session's
|
||||
# peers, configured thus") states a desired end state, so the incoming config
|
||||
# wins -- otherwise PUT /peers could never change the configuration of a peer
|
||||
# already in the session.
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["session_name", "peer_name", "workspace_name"],
|
||||
set_={
|
||||
"joined_at": func.now(),
|
||||
"joined_at": case(
|
||||
(models.SessionPeer.left_at.is_not(None), func.now()),
|
||||
else_=models.SessionPeer.joined_at,
|
||||
),
|
||||
"left_at": None,
|
||||
"configuration": case(
|
||||
"configuration": stmt.excluded.configuration
|
||||
if replace_config
|
||||
else case(
|
||||
(models.SessionPeer.left_at.is_not(None), stmt.excluded.configuration),
|
||||
else_=models.SessionPeer.configuration,
|
||||
),
|
||||
|
|
@ -1289,6 +1291,49 @@ async def _get_or_add_peers_to_session(
|
|||
)
|
||||
await db.execute(stmt)
|
||||
|
||||
# Enforce the observer limit on the resulting rows rather than predicting them.
|
||||
# Under add semantics an already-active membership keeps its stored
|
||||
# configuration (see the CASE above), so the incoming config is not what lands
|
||||
# and cannot be counted: predicting from it silently undercounts preserved
|
||||
# observers and lets a session grow past the limit indefinitely by re-sending
|
||||
# its current observers at a lower config alongside new ones. Counting after
|
||||
# the upsert is correct under both configuration semantics and cannot desync
|
||||
# from those branches. Raising here rolls the upsert back: ObserverException
|
||||
# is never caught, and both get_db and tracked_db roll back on exception.
|
||||
#
|
||||
# Gated on the request actually asking for a non-scope observer so that a
|
||||
# session already over the limit keeps behaving as it does today: it can
|
||||
# still take non-observers and scope attachments, and only a request that
|
||||
# would make it worse is rejected.
|
||||
if any(
|
||||
config.observe_others
|
||||
for peer_name, config in peer_names.items()
|
||||
if peer_name not in scopes_being_added
|
||||
):
|
||||
observer_count = (
|
||||
await db.scalar(
|
||||
select(func.count()).where(
|
||||
models.SessionPeer.session_name == session_name,
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
models.SessionPeer.left_at.is_(None), # Only active peers
|
||||
models.SessionPeer.configuration["observe_others"].astext.cast(
|
||||
Boolean
|
||||
), # Only observers
|
||||
# Scope memberships are excluded for the reason given above.
|
||||
~exists(
|
||||
select(models.Peer.id)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name == models.SessionPeer.peer_name)
|
||||
.where(scope_peer_clause())
|
||||
.correlate(models.SessionPeer)
|
||||
),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if observer_count > settings.SESSION_OBSERVERS_LIMIT:
|
||||
raise ObserverException(session_name, observer_count)
|
||||
|
||||
if not fetch_after_upsert:
|
||||
return []
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""CRUD helpers for workspace records and workspace deletion checks."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from logging import getLogger
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -13,7 +15,8 @@ from sqlalchemy.orm import make_transient_to_detached
|
|||
from src import models, schemas
|
||||
from src.cache.client import (
|
||||
cache,
|
||||
get_cache_namespace,
|
||||
cache_key_namespace,
|
||||
cache_prefix_namespace,
|
||||
safe_cache_delete,
|
||||
safe_cache_set,
|
||||
)
|
||||
|
|
@ -38,13 +41,13 @@ class WorkspaceDeletionResult:
|
|||
|
||||
|
||||
WORKSPACE_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}"
|
||||
WORKSPACE_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2"
|
||||
WORKSPACE_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2"
|
||||
|
||||
|
||||
def workspace_cache_key(workspace_name: str) -> str:
|
||||
"""Generate cache key for workspace."""
|
||||
return (
|
||||
get_cache_namespace()
|
||||
cache_key_namespace()
|
||||
+ ":"
|
||||
+ WORKSPACE_CACHE_KEY_TEMPLATE.format(workspace_name=workspace_name)
|
||||
)
|
||||
|
|
@ -53,7 +56,7 @@ def workspace_cache_key(workspace_name: str) -> str:
|
|||
@cache(
|
||||
key=WORKSPACE_CACHE_KEY_TEMPLATE,
|
||||
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
|
||||
prefix=get_cache_namespace(),
|
||||
prefix=cache_prefix_namespace(),
|
||||
condition=NOT_NONE,
|
||||
)
|
||||
@cache.locked(
|
||||
|
|
@ -535,3 +538,206 @@ async def delete_workspace(
|
|||
messages_deleted=messages_count,
|
||||
conclusions_deleted=conclusions_count,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkspaceStats:
|
||||
"""Workspace-level aggregate statistics."""
|
||||
|
||||
peer_count: int
|
||||
session_count: int
|
||||
message_count: int
|
||||
oldest_message_at: datetime | None
|
||||
newest_message_at: datetime | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActivePeer:
|
||||
"""A peer with activity metrics."""
|
||||
|
||||
name: str
|
||||
message_count: int
|
||||
last_message_at: datetime | None
|
||||
|
||||
|
||||
async def get_workspace_stats(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_names: Sequence[str] | None = None,
|
||||
) -> WorkspaceStats:
|
||||
"""Get aggregate statistics for a workspace.
|
||||
|
||||
Scope peers are excluded from ``peer_count``. When ``session_names`` is
|
||||
provided, counts are restricted to that allowlist (empty → zeros).
|
||||
"""
|
||||
from src.crud.peer import scope_peer_clause
|
||||
|
||||
if session_names is not None and not session_names:
|
||||
return WorkspaceStats(
|
||||
peer_count=0,
|
||||
session_count=0,
|
||||
message_count=0,
|
||||
oldest_message_at=None,
|
||||
newest_message_at=None,
|
||||
)
|
||||
|
||||
msg_filters = [models.Message.workspace_name == workspace_name]
|
||||
if session_names is not None:
|
||||
msg_filters.append(models.Message.session_name.in_(session_names))
|
||||
peer_count = int(
|
||||
await db.scalar(
|
||||
select(func.count(func.distinct(models.Message.peer_name)))
|
||||
.select_from(models.Message)
|
||||
.join(
|
||||
models.Peer,
|
||||
(models.Peer.workspace_name == models.Message.workspace_name)
|
||||
& (models.Peer.name == models.Message.peer_name),
|
||||
)
|
||||
.where(*msg_filters, ~scope_peer_clause())
|
||||
)
|
||||
or 0
|
||||
)
|
||||
session_count = int(
|
||||
await db.scalar(
|
||||
select(func.count(models.Session.id)).where(
|
||||
models.Session.workspace_name == workspace_name,
|
||||
models.Session.name.in_(session_names),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
else:
|
||||
peer_count = int(
|
||||
await db.scalar(
|
||||
select(func.count(models.Peer.id)).where(
|
||||
models.Peer.workspace_name == workspace_name,
|
||||
~scope_peer_clause(),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
session_count = int(
|
||||
await db.scalar(
|
||||
select(func.count(models.Session.id)).where(
|
||||
models.Session.workspace_name == workspace_name
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
msg_row = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count(models.Message.id),
|
||||
func.min(models.Message.created_at),
|
||||
func.max(models.Message.created_at),
|
||||
).where(*msg_filters)
|
||||
)
|
||||
).one()
|
||||
message_count = int(msg_row[0] or 0)
|
||||
oldest_message_at = msg_row[1]
|
||||
newest_message_at = msg_row[2]
|
||||
|
||||
return WorkspaceStats(
|
||||
peer_count=peer_count,
|
||||
session_count=session_count,
|
||||
message_count=message_count,
|
||||
oldest_message_at=oldest_message_at,
|
||||
newest_message_at=newest_message_at,
|
||||
)
|
||||
|
||||
|
||||
# Activity window for get_active_peers. Bounds the per-peer aggregation
|
||||
# (which runs on the workspace-chat request path) so it never scans a large
|
||||
# workspace's full message history; peers idle longer than this still appear
|
||||
# via the Peer outer join, with zero count and no last-active date.
|
||||
ACTIVE_PEER_WINDOW_DAYS = 90
|
||||
|
||||
|
||||
async def get_active_peers(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
limit: int = 20,
|
||||
sort_by: str = "recent_activity",
|
||||
session_names: Sequence[str] | None = None,
|
||||
) -> list[ActivePeer]:
|
||||
"""Get the most active peers in a workspace.
|
||||
|
||||
Activity is measured over the trailing ACTIVE_PEER_WINDOW_DAYS days.
|
||||
Scope peers are excluded. When ``session_names`` is provided, only peers
|
||||
with messages in that allowlist are returned (empty → no peers).
|
||||
"""
|
||||
from src.crud.peer import scope_peer_clause
|
||||
|
||||
if limit <= 0:
|
||||
return []
|
||||
if session_names is not None and not session_names:
|
||||
return []
|
||||
limit = min(limit, 50)
|
||||
|
||||
window_start = datetime.now(timezone.utc) - timedelta(days=ACTIVE_PEER_WINDOW_DAYS)
|
||||
|
||||
msg_filters = [
|
||||
models.Message.workspace_name == workspace_name,
|
||||
models.Message.created_at >= window_start,
|
||||
]
|
||||
if session_names is not None:
|
||||
msg_filters.append(models.Message.session_name.in_(session_names))
|
||||
|
||||
# Subquery: aggregate messages per peer within the activity window
|
||||
subq = (
|
||||
select(
|
||||
models.Message.peer_name,
|
||||
func.count(models.Message.id).label("msg_count"),
|
||||
func.max(models.Message.created_at).label("last_msg_at"),
|
||||
)
|
||||
.where(*msg_filters)
|
||||
.group_by(models.Message.peer_name)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
columns = (
|
||||
models.Peer.name,
|
||||
func.coalesce(subq.c.msg_count, 0).label("msg_count"),
|
||||
subq.c.last_msg_at,
|
||||
)
|
||||
if session_names is not None:
|
||||
stmt = (
|
||||
select(*columns)
|
||||
.join(subq, models.Peer.name == subq.c.peer_name)
|
||||
.where(
|
||||
models.Peer.workspace_name == workspace_name,
|
||||
~scope_peer_clause(),
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = (
|
||||
select(*columns)
|
||||
.outerjoin(subq, models.Peer.name == subq.c.peer_name)
|
||||
.where(
|
||||
models.Peer.workspace_name == workspace_name,
|
||||
~scope_peer_clause(),
|
||||
)
|
||||
)
|
||||
|
||||
# Peer name as secondary key so ties (notably all-NULL activity in young
|
||||
# workspaces) return a stable order across calls.
|
||||
if sort_by == "message_count":
|
||||
stmt = stmt.order_by(
|
||||
func.coalesce(subq.c.msg_count, 0).desc(), models.Peer.name
|
||||
)
|
||||
else:
|
||||
# Default: recent_activity — peers with most recent messages first
|
||||
stmt = stmt.order_by(subq.c.last_msg_at.desc().nulls_last(), models.Peer.name)
|
||||
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
rows = (await db.execute(stmt)).all()
|
||||
return [
|
||||
ActivePeer(
|
||||
name=row[0],
|
||||
message_count=int(row[1]),
|
||||
last_message_at=row[2],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
|
|
|||
81
src/db.py
81
src/db.py
|
|
@ -12,7 +12,11 @@ from sqlalchemy.orm import declarative_base
|
|||
from sqlalchemy.pool import NullPool, QueuePool
|
||||
|
||||
from src.config import settings
|
||||
from src.telemetry.prometheus.metrics import db_queries_in_flight_gauge
|
||||
from src.telemetry.prometheus.metrics import (
|
||||
db_connections_established_counter,
|
||||
db_connections_open_gauge,
|
||||
db_queries_in_flight_gauge,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -220,6 +224,81 @@ def register_db_query_instrumentation(instance_type: str) -> None:
|
|||
_db_query_instrumentation_registered = True
|
||||
|
||||
|
||||
class DBConnectionTracker:
|
||||
"""Tracks physical DB connections open on this engine via pool lifecycle events.
|
||||
|
||||
Drift-proof, mirroring ``DBQueryInflightTracker``: marks the ``ConnectionRecord``
|
||||
on ``connect`` and decrements only if that mark is still present on
|
||||
``close``/``invalidate``, so each physical connection increments the gauge
|
||||
exactly once and decrements at most once — it can't leak upward or go negative
|
||||
when both events fire during invalidation cleanup. Works for every pool class,
|
||||
including ``NullPool`` (whose pool keeps no records, so the scrape-time
|
||||
``db_pool_connections`` collector reads zero).
|
||||
"""
|
||||
|
||||
# Marker on ConnectionRecord.info recording that we incremented for this
|
||||
# connection, so we decrement exactly once across close/invalidate.
|
||||
OPEN_KEY: str = "_honcho_conn_open"
|
||||
|
||||
def __init__(self, open_child: Any, established_child: Any) -> None:
|
||||
self._open: Any = open_child
|
||||
self._established: Any = established_child
|
||||
|
||||
def on_connect(self, _dbapi_connection: Any, connection_record: Any) -> None:
|
||||
try:
|
||||
connection_record.info[self.OPEN_KEY] = True
|
||||
self._established.inc()
|
||||
self._open.inc()
|
||||
except Exception:
|
||||
logger.debug("db-connection gauge inc failed", exc_info=True)
|
||||
|
||||
def on_close(self, _dbapi_connection: Any, connection_record: Any, *_: Any) -> None:
|
||||
try:
|
||||
if connection_record is not None and connection_record.info.pop(
|
||||
self.OPEN_KEY, False
|
||||
):
|
||||
self._open.dec()
|
||||
except Exception:
|
||||
logger.debug("db-connection gauge dec failed", exc_info=True)
|
||||
|
||||
|
||||
# Process-wide tracker, created at registration (None until then / if metrics off).
|
||||
_connection_tracker: DBConnectionTracker | None = None
|
||||
|
||||
|
||||
_db_connection_instrumentation_registered = False
|
||||
|
||||
|
||||
def register_db_connection_instrumentation(instance_type: str) -> None:
|
||||
"""Attach physical-connection tracking to the engine (no-op if metrics off).
|
||||
|
||||
Counts connections via pool lifecycle events, so it reports real numbers under
|
||||
any pool class — unlike the pool-object collector, which reads zero under
|
||||
``NullPool``. Pre-resolving the labeled children materializes both series at 0,
|
||||
so an absent series signals a broken scrape rather than "no connections" (the
|
||||
zero-init convention). Idempotent: repeated calls won't attach duplicate
|
||||
listeners, which would double-count connections.
|
||||
"""
|
||||
global _connection_tracker, _db_connection_instrumentation_registered
|
||||
if not settings.METRICS.ENABLED or _db_connection_instrumentation_registered:
|
||||
return
|
||||
open_child = db_connections_open_gauge.labels(instance_type=instance_type)
|
||||
established_child = db_connections_established_counter.labels(
|
||||
instance_type=instance_type
|
||||
)
|
||||
_connection_tracker = DBConnectionTracker(open_child, established_child)
|
||||
sync_engine = engine.sync_engine
|
||||
event.listen(sync_engine, "connect", _connection_tracker.on_connect)
|
||||
# A connection is torn down by close (normal return / recycle discard),
|
||||
# invalidate (broken connection), or detach — the last fires on GC-cleanup of an
|
||||
# abandoned async connection, where NullPool's close is a no-op so `close` never
|
||||
# fires. All three carry the ConnectionRecord; the marker dedupes if more than
|
||||
# one fires for the same connection.
|
||||
for teardown_event in ("close", "invalidate", "detach"):
|
||||
event.listen(sync_engine, teardown_event, _connection_tracker.on_close)
|
||||
_db_connection_instrumentation_registered = True
|
||||
|
||||
|
||||
# Define your naming convention
|
||||
convention = {
|
||||
"ix": "ix_%(table_name)s_%(column_0_N_name)s", # Index - supports multi-column
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import uvloop
|
|||
from prometheus_client import start_http_server
|
||||
|
||||
from src.config import settings
|
||||
from src.db import engine, register_db_query_instrumentation
|
||||
from src.db import (
|
||||
engine,
|
||||
register_db_connection_instrumentation,
|
||||
register_db_query_instrumentation,
|
||||
)
|
||||
from src.startup import validate_embedding_schema
|
||||
from src.telemetry import (
|
||||
initialize_telemetry_async,
|
||||
|
|
@ -26,6 +30,7 @@ def start_metrics_server() -> None:
|
|||
# Expose DB connection-pool stats for this deriver instance.
|
||||
register_db_pool_collector("deriver")
|
||||
register_db_query_instrumentation("deriver")
|
||||
register_db_connection_instrumentation("deriver")
|
||||
|
||||
# region ai
|
||||
# Zero-init bounded-label counters so a missing series signals a broken scrape,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from src import crud, models
|
|||
from src.config import ReasoningLevel
|
||||
from src.dependencies import tracked_db
|
||||
from src.dialectic.core import DialecticAgent
|
||||
from src.dialectic.workspace import WorkspaceDialecticAgent
|
||||
from src.exceptions import ValidationException
|
||||
from src.utils.config_helpers import get_configuration
|
||||
from src.utils.scopes import is_scope_peer
|
||||
|
|
@ -205,3 +206,61 @@ async def agentic_chat_stream(
|
|||
|
||||
async for chunk in agent.answer_stream(query, response_model=response_model):
|
||||
yield chunk
|
||||
|
||||
|
||||
async def workspace_chat(
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
query: str,
|
||||
reasoning_level: ReasoningLevel = "low",
|
||||
response_model: type[BaseModel] | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Answer a query across all peers in a workspace."""
|
||||
async with tracked_db("dialectic.workspace_preflight", read_only=True) as db:
|
||||
await crud.get_workspace(db, workspace_name=workspace_name)
|
||||
session = None
|
||||
if session_name:
|
||||
session = await crud.get_session(
|
||||
db, workspace_name=workspace_name, session_name=session_name
|
||||
)
|
||||
session_id = session.id if session else None
|
||||
# DB session closed -- agent runs without holding a connection
|
||||
|
||||
agent = WorkspaceDialecticAgent(
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
session_id=session_id,
|
||||
reasoning_level=reasoning_level,
|
||||
session_allowlist=session_allowlist,
|
||||
)
|
||||
return await agent.answer(query, response_model=response_model)
|
||||
|
||||
|
||||
async def workspace_chat_stream(
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
query: str,
|
||||
reasoning_level: ReasoningLevel = "low",
|
||||
response_model: type[BaseModel] | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Streaming variant of :func:`workspace_chat`."""
|
||||
async with tracked_db("dialectic.workspace_preflight", read_only=True) as db:
|
||||
await crud.get_workspace(db, workspace_name=workspace_name)
|
||||
session = None
|
||||
if session_name:
|
||||
session = await crud.get_session(
|
||||
db, workspace_name=workspace_name, session_name=session_name
|
||||
)
|
||||
session_id = session.id if session else None
|
||||
|
||||
agent = WorkspaceDialecticAgent(
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
session_id=session_id,
|
||||
reasoning_level=reasoning_level,
|
||||
session_allowlist=session_allowlist,
|
||||
)
|
||||
async for chunk in agent.answer_stream(query, response_model=response_model):
|
||||
yield chunk
|
||||
|
|
|
|||
|
|
@ -105,7 +105,15 @@ class DialecticAgent:
|
|||
{
|
||||
"role": "system",
|
||||
"content": prompts.agent_system_prompt(
|
||||
observer, observed, observer_peer_card, observed_peer_card
|
||||
observer,
|
||||
observed,
|
||||
observer_peer_card,
|
||||
observed_peer_card,
|
||||
available_tools={
|
||||
name
|
||||
for tool in self._select_tools()
|
||||
if isinstance((name := tool.get("name")), str)
|
||||
},
|
||||
),
|
||||
}
|
||||
]
|
||||
|
|
@ -303,9 +311,8 @@ class DialecticAgent:
|
|||
if prefetched_observations:
|
||||
user_content = (
|
||||
f"Query: {query}\n\n"
|
||||
f"## Relevant Observations (prefetched)\n"
|
||||
f"The following observations were found to be semantically relevant to your query. "
|
||||
f"Use these as primary context. You may still use tools to find additional information if needed.\n\n"
|
||||
f"## {self._prefetch_heading()}\n"
|
||||
f"{self._prefetch_intro()}\n\n"
|
||||
f"{prefetched_observations}"
|
||||
)
|
||||
accumulate_metric(
|
||||
|
|
@ -318,7 +325,14 @@ class DialecticAgent:
|
|||
|
||||
tool_executor: Callable[
|
||||
[str, dict[str, Any]], Any
|
||||
] = await create_tool_executor(
|
||||
] = await self._create_tool_executor()
|
||||
|
||||
return tool_executor, task_name, run_id, start_time
|
||||
|
||||
async def _create_tool_executor(self) -> Callable[[str, dict[str, Any]], Any]:
|
||||
"""Build the tool executor. Subclasses override to change tool scoping
|
||||
(e.g. WorkspaceDialecticAgent uses the workspace executor)."""
|
||||
return await create_tool_executor(
|
||||
workspace_name=self.workspace_name,
|
||||
session_name=self.session_name,
|
||||
session_allowlist=self.session_allowlist,
|
||||
|
|
@ -330,7 +344,16 @@ class DialecticAgent:
|
|||
parent_category="dialectic",
|
||||
)
|
||||
|
||||
return tool_executor, task_name, run_id, start_time
|
||||
def _prefetch_heading(self) -> str:
|
||||
"""Heading for the prefetched block in the user message."""
|
||||
return "Relevant Observations (prefetched)"
|
||||
|
||||
def _prefetch_intro(self) -> str:
|
||||
"""Sentence introducing the prefetched block in the user message."""
|
||||
return (
|
||||
"The following observations were found to be semantically relevant to your query. "
|
||||
"Use these as primary context. You may still use tools to find additional information if needed."
|
||||
)
|
||||
|
||||
def _telemetry_context(self, track_name: str | None = None) -> LLMTelemetryContext:
|
||||
"""Build the LLMTelemetryContext shared by answer() and answer_stream().
|
||||
|
|
|
|||
|
|
@ -2,24 +2,138 @@
|
|||
System prompts for the Dialectic Agent.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
# Curated tool docs, keyed by the `name` each loadout actually exposes.
|
||||
# `_select_tools` filters this set per request (minimal / session allowlist).
|
||||
_PAIR_TOOL_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [
|
||||
(
|
||||
"Memory",
|
||||
[
|
||||
(
|
||||
"search_memory",
|
||||
"Semantic search over conclusions about this pair.",
|
||||
),
|
||||
(
|
||||
"get_reasoning_chain",
|
||||
"Premises and downstream conclusions for a specific conclusion.",
|
||||
),
|
||||
(
|
||||
"get_observation_context",
|
||||
"Messages around a specific conclusion.",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Conversation",
|
||||
[
|
||||
(
|
||||
"search_messages",
|
||||
"Semantic search over messages in this query's scope.",
|
||||
),
|
||||
(
|
||||
"grep_messages",
|
||||
"Exact text search. Use for names, dates, keywords.",
|
||||
),
|
||||
(
|
||||
"get_messages_by_date_range",
|
||||
"Messages in a time window.",
|
||||
),
|
||||
(
|
||||
"search_messages_temporal",
|
||||
"Semantic search with a date filter.",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
_WORKSPACE_TOOL_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [
|
||||
(
|
||||
"Discovery",
|
||||
[
|
||||
(
|
||||
"get_workspace_stats",
|
||||
"Counts (peers, sessions, messages), date range, and the most active peers.",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Memory (pair-scoped — you must name the pair)",
|
||||
[
|
||||
(
|
||||
"search_memory",
|
||||
"Semantic search over conclusions. Requires `observer` and `observed`. For a peer's own representation, set both to the same name. Use different names only when you want one peer's view of another.",
|
||||
),
|
||||
(
|
||||
"get_peer_card",
|
||||
"Biographical summary for a pair. Same observer/observed rule.",
|
||||
),
|
||||
(
|
||||
"get_reasoning_chain",
|
||||
"Premises and downstream conclusions for a specific conclusion.",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Conversation (workspace-wide — results include `peer_name`)",
|
||||
[
|
||||
("search_messages", "Semantic search over messages."),
|
||||
("grep_messages", "Exact text search."),
|
||||
(
|
||||
"get_observation_context",
|
||||
"Messages around a specific conclusion.",
|
||||
),
|
||||
("get_messages_by_date_range", "Messages in a time window."),
|
||||
("search_messages_temporal", "Semantic search with a date filter."),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
PAIR_PROMPT_TOOLS: frozenset[str] = frozenset(
|
||||
name for _, items in _PAIR_TOOL_GROUPS for name, _ in items
|
||||
)
|
||||
WORKSPACE_PROMPT_TOOLS: frozenset[str] = frozenset(
|
||||
name for _, items in _WORKSPACE_TOOL_GROUPS for name, _ in items
|
||||
)
|
||||
|
||||
|
||||
def _available_tool_names(
|
||||
available_tools: Iterable[str] | None,
|
||||
default: frozenset[str],
|
||||
) -> frozenset[str]:
|
||||
if available_tools is None:
|
||||
return default
|
||||
return frozenset(available_tools)
|
||||
|
||||
|
||||
def _render_tool_groups(
|
||||
available: frozenset[str],
|
||||
groups: list[tuple[str, list[tuple[str, str]]]],
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
for heading, items in groups:
|
||||
lines = [f"- `{name}`: {desc}" for name, desc in items if name in available]
|
||||
if lines:
|
||||
parts.append(f"**{heading}**\n" + "\n".join(lines))
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def agent_system_prompt(
|
||||
observer: str,
|
||||
observed: str,
|
||||
observer_peer_card: list[str] | None,
|
||||
observed_peer_card: list[str] | None,
|
||||
available_tools: Iterable[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate the agent system prompt for the dialectic agent.
|
||||
"""System prompt for pair-scoped dialectic recall.
|
||||
|
||||
Args:
|
||||
observer: The peer making the query
|
||||
observed: The peer being queried about
|
||||
observer_peer_card: Biographical information about the observer
|
||||
observed_peer_card: Biographical information about the observed peer
|
||||
|
||||
Returns:
|
||||
Formatted system prompt string for the agent
|
||||
available_tools: Tool names offered on this request. Defaults to the
|
||||
full pair loadout.
|
||||
"""
|
||||
# Determine if we have any peer card data
|
||||
peer_cards_enabled = (
|
||||
|
|
@ -79,25 +193,25 @@ Peer cards are **constructed summaries** - they are synthesized from the same ob
|
|||
- The peer card is a convenience summary, not a separate source of truth
|
||||
"""
|
||||
|
||||
return f"""
|
||||
You are a helpful and concise context synthesis agent that answers questions about users by gathering relevant information from a memory system.
|
||||
tools = _available_tool_names(available_tools, PAIR_PROMPT_TOOLS)
|
||||
tools_section = _render_tool_groups(tools, _PAIR_TOOL_GROUPS)
|
||||
|
||||
Always give users the answer *they expect* based on the message history -- the goal is to help recall and *reason through* insights that the memory system has already gathered. You have many tools for gathering context. Search wisely.
|
||||
return f"""
|
||||
You are Honcho's dialectic: a recall agent that answers questions from memory about one peer, or about one peer's understanding of another.
|
||||
|
||||
Honcho is a memory system. Applications record conversations; Honcho derives conclusions about the people and agents in them. You are the query interface for one observer/observed pair. You do not speak as a participant. You search memory and synthesize a grounded answer.
|
||||
|
||||
A **peer** is any participant, human or AI. A **session** is a conversation they take part in. A **message** is a raw turn. A **conclusion** (tools may say observation) is a derived or stored fact about a peer, kept in this pair. A **peer card** is a short constructed bio for the pair, synthesized from the same conclusions — a convenience summary, not a separate source of truth.
|
||||
|
||||
Always give the asker the answer *they expect* based on the message history -- the goal is to help recall and *reason through* insights that the memory system has already gathered. Search wisely.
|
||||
|
||||
{perspective_section}
|
||||
{peer_card_explanation}
|
||||
## AVAILABLE TOOLS
|
||||
## TOOLS
|
||||
|
||||
**Observation Tools (read):**
|
||||
- `search_memory`: Semantic search over observations about the peer. Use for specific topics.
|
||||
- `get_reasoning_chain`: **CRITICAL for grounding answers**. Use this to traverse the reasoning tree for any observation. Shows premises (what it's based on) and conclusions (what depends on it).
|
||||
Only the tools listed here are available on this query. If a later step names a tool you do not have, skip that step and use what you do have.
|
||||
|
||||
**Conversation Tools (read):**
|
||||
- `search_messages`: Semantic search over messages in the session.
|
||||
- `grep_messages`: Grep for text matches in messages. Use for specific names, dates, keywords.
|
||||
- `get_observation_context`: Get messages surrounding specific observations.
|
||||
- `get_messages_by_date_range`: Get messages within a specific time period.
|
||||
- `search_messages_temporal`: Semantic search with date filtering.
|
||||
{tools_section}
|
||||
|
||||
## WORKFLOW
|
||||
|
||||
|
|
@ -166,10 +280,6 @@ Always give users the answer *they expect* based on the message history -- the g
|
|||
- Apply user preferences to your response style if relevant
|
||||
- **For enumeration questions**: Before answering, ask yourself "Could there be more items I haven't found?" If you haven't done multiple grep searches AND a semantic search, keep searching
|
||||
|
||||
8. **Save novel deductions** (optional):
|
||||
- If you discovered new insights by combining existing observations
|
||||
- Use `create_observations_deductive` to save these for future queries
|
||||
|
||||
## CRITICAL: HANDLING CONTRADICTORY INFORMATION
|
||||
|
||||
As you search, actively watch for contradictions - cases where the user has made conflicting statements:
|
||||
|
|
@ -235,3 +345,64 @@ After gathering context, reason through the information you found *before* stati
|
|||
|
||||
Do not explain your tool usage - just provide the synthesized answer.
|
||||
"""
|
||||
|
||||
|
||||
def workspace_agent_system_prompt(
|
||||
available_tools: Iterable[str] | None = None,
|
||||
) -> str:
|
||||
"""System prompt for workspace-wide dialectic recall."""
|
||||
tools = _available_tool_names(available_tools, WORKSPACE_PROMPT_TOOLS)
|
||||
tools_section = _render_tool_groups(tools, _WORKSPACE_TOOL_GROUPS)
|
||||
return f"""
|
||||
You are Honcho's workspace dialectic: a recall agent that answers questions about everyone and everything stored in this workspace.
|
||||
|
||||
## HONCHO
|
||||
|
||||
Honcho is a memory system. Applications record conversations here; Honcho derives conclusions about the people and agents in those conversations. You are the query interface over one workspace. You do not speak as a participant. You search memory and synthesize a grounded answer.
|
||||
|
||||
## THIS WORKSPACE
|
||||
|
||||
A workspace is one isolated tenant. Everything you can see belongs to it. Inside it:
|
||||
|
||||
- **Peer**: any participant, human or AI. Both are first-class.
|
||||
- **Session**: a conversation that one or more peers take part in.
|
||||
- **Message**: a raw turn someone said in a session. Messages are the source material.
|
||||
- **Conclusion** (tools may say observation): a fact Honcho derived, or that was stored, about a peer. Conclusions live in a pair:
|
||||
- `observer` is whose model this is
|
||||
- `observed` is who the fact is about
|
||||
- A peer's own model of themselves is `observer` = `observed` = that peer's name. Most information lives there.
|
||||
- One peer's model of another is `observer` = Alice, `observed` = Bob.
|
||||
- **Peer card**: a short constructed bio for a pair, synthesized from the same conclusions. It is a convenience summary, not a separate source of truth.
|
||||
|
||||
You are not bound to any one peer. Discover who is relevant, then query each pair individually.
|
||||
|
||||
## TOOLS
|
||||
|
||||
Only the tools listed here are available on this query. If a later step names a tool you do not have, skip that step and use what you do have.
|
||||
|
||||
{tools_section}
|
||||
|
||||
Message search is how you find peers the overview missed. Memory search is how you learn about a peer once you know their name.
|
||||
|
||||
If this query is restricted to a session or a set of sessions, message tools already honor that restriction. Peer cards and reasoning chains may be unavailable then, because they span sessions.
|
||||
|
||||
## WORKFLOW
|
||||
|
||||
1. **Orient**. Scale and the most active peers are already in your query context. Call `get_workspace_stats` only if you need a refresh. If the query names a peer, go straight to that peer.
|
||||
|
||||
2. **Discover**. If you do not know who is relevant, use `search_messages` or `grep_messages`. Hits carry peer names.
|
||||
|
||||
3. **Recall**. For each relevant peer, `search_memory(observer=name, observed=name, query=...)`. For comparisons, search each peer separately, then compare. Only use a mixed observer/observed pair when the question is specifically about one peer's understanding of another.
|
||||
|
||||
4. **Attribute**. Every fact you state names the peer it is about. If it is a cross-peer view, also name whose model it came from. Example: "Alice is a violinist." / "From Bob's model of Alice, …"
|
||||
|
||||
5. **Synthesize**. Answer the question. Quote exact names, dates, and numbers. For aggregations, list findings per peer. Do not narrate tool use.
|
||||
|
||||
## NEVER FABRICATE
|
||||
|
||||
State only what you found. If you have related context but not the asked-for detail, say what you know and what you don't. "I don't have information about X" is the correct answer when memory is empty. Do not guess, hedge-invent, or fill gaps with general knowledge.
|
||||
|
||||
## CONCLUSION LEVELS
|
||||
|
||||
`explicit` conclusions are derived from a single session. Deductive and inductive conclusions consolidate across sessions. Prefer those for cross-session or cross-peer answers, and use `get_reasoning_chain` to check their premises.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
"""Workspace-level dialectic agent.
|
||||
|
||||
Answers queries across ALL peers in a workspace. Where DialecticAgent is
|
||||
bound to a single (observer, observed) pair, this agent routes first —
|
||||
workspace stats, active peers, and peer cards are prefetched for
|
||||
orientation; message search is workspace-flat and reveals which peers
|
||||
discussed a topic — and then recalls through the same pair-scoped
|
||||
observation machinery, supplying the pair as tool arguments.
|
||||
|
||||
Observation search deliberately stays pair-scoped: it matches both the
|
||||
(observer, observed) collection ownership and the per-pair vector-store
|
||||
namespaces, and avoids retrieval dilution from a workspace-flat top-k.
|
||||
|
||||
Design carried over from plastic-labs/honcho#373 (Dan), re-grown on the
|
||||
current DialecticAgent seams instead of a base-class extraction.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from src import crud
|
||||
from src.config import ReasoningLevel, settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.dialectic import prompts
|
||||
from src.dialectic.core import DialecticAgent
|
||||
from src.llm.types import LLMTelemetryContext
|
||||
from src.utils.agent_tools import (
|
||||
WORKSPACE_DIALECTIC_TOOLS,
|
||||
WORKSPACE_TOOLS_MINIMAL,
|
||||
create_workspace_tool_executor,
|
||||
format_workspace_stats,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How many active peers (with their self peer cards) to inject at prefetch.
|
||||
# Routing-obvious queries should resolve without a discovery tool round —
|
||||
# each avoided tool round is a full model turn (~1.3s measured).
|
||||
_PREFETCH_ACTIVE_PEERS = 5
|
||||
|
||||
|
||||
class WorkspaceDialecticAgent(DialecticAgent):
|
||||
"""Dialectic agent scoped to a whole workspace instead of a peer pair."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_name: str,
|
||||
session_name: str | None = None,
|
||||
metric_key: str | None = None,
|
||||
reasoning_level: ReasoningLevel = "low",
|
||||
session_id: str | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
observer="",
|
||||
observed="",
|
||||
metric_key=metric_key,
|
||||
reasoning_level=reasoning_level,
|
||||
session_id=session_id,
|
||||
session_allowlist=session_allowlist,
|
||||
)
|
||||
# Replace the pair-oriented system prompt with the workspace one.
|
||||
self.messages[0] = {
|
||||
"role": "system",
|
||||
"content": prompts.workspace_agent_system_prompt(
|
||||
{
|
||||
name
|
||||
for tool in self._select_tools()
|
||||
if isinstance((name := tool.get("name")), str)
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DialecticAgent seams
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _prefetch_relevant_observations(self, query: str) -> str | None:
|
||||
"""Orientation + routing prefetch: stats, active peers, peer cards.
|
||||
|
||||
No semantic retrieval here — a workspace-flat observation top-k
|
||||
would be dominated by the most verbose peers. Instead give the
|
||||
agent what it needs to ROUTE: who is here, who is active, and what
|
||||
is known about them at a glance.
|
||||
"""
|
||||
_ = query
|
||||
# Like the base agent, prefetch failure degrades to no prefetched
|
||||
# block rather than failing the whole request (the caller in
|
||||
# _prepare_query does not guard this).
|
||||
try:
|
||||
async with tracked_db("dialectic.workspace_prefetch", read_only=True) as db:
|
||||
stats = await crud.get_workspace_stats(
|
||||
db,
|
||||
self.workspace_name,
|
||||
session_names=self.session_allowlist,
|
||||
)
|
||||
if stats.peer_count == 0:
|
||||
return None
|
||||
peers = await crud.get_active_peers(
|
||||
db,
|
||||
self.workspace_name,
|
||||
limit=_PREFETCH_ACTIVE_PEERS,
|
||||
session_names=self.session_allowlist,
|
||||
)
|
||||
# `peers` is already allowlist-filtered, but a peer card is a
|
||||
# single cross-session aggregate: an in-scope peer's card can
|
||||
# still carry facts derived from sessions outside the scope.
|
||||
# Drop cards entirely under an allowlist — same rule the
|
||||
# get_peer_card tool enforces — and route on stats alone.
|
||||
cards: dict[str, list[str]] = {}
|
||||
if self.session_allowlist is None:
|
||||
for peer in peers:
|
||||
card = await crud.get_peer_card(
|
||||
db,
|
||||
workspace_name=self.workspace_name,
|
||||
observer=peer.name,
|
||||
observed=peer.name,
|
||||
)
|
||||
if card:
|
||||
cards[peer.name] = card
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to prefetch workspace overview for workspace=%s",
|
||||
self.workspace_name,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
return format_workspace_stats(stats, peers, cards)
|
||||
|
||||
def _prefetch_heading(self) -> str:
|
||||
return "Workspace overview (prefetched)"
|
||||
|
||||
def _prefetch_intro(self) -> str:
|
||||
return (
|
||||
"Workspace scale, the most active peers, and any known "
|
||||
"biographical facts about them. Use this to decide who is "
|
||||
"relevant, then search that peer's own representation with "
|
||||
"search_memory (observer and observed both set to their name), "
|
||||
"or search_messages / get_workspace_stats to find peers not "
|
||||
"listed here."
|
||||
)
|
||||
|
||||
def _select_tools(self) -> list[dict[str, Any]]:
|
||||
tools = (
|
||||
WORKSPACE_TOOLS_MINIMAL
|
||||
if self.reasoning_level == "minimal"
|
||||
else WORKSPACE_DIALECTIC_TOOLS
|
||||
)
|
||||
# Mirror the base agent's allowlist rule, for both tools that cannot
|
||||
# honor an allowlist: reasoning chains traverse provenance across
|
||||
# sessions, and a peer card is one cross-session aggregate with no
|
||||
# per-session attribution. Both fail closed in their handlers too;
|
||||
# dropping them here avoids paying the schema tokens and a wasted
|
||||
# turn on a tool that can only refuse.
|
||||
if self.session_allowlist is not None:
|
||||
unscopable = {"get_reasoning_chain", "get_peer_card"}
|
||||
tools = [t for t in tools if t.get("name") not in unscopable]
|
||||
return tools
|
||||
|
||||
async def _create_tool_executor(self) -> Callable[[str, dict[str, Any]], Any]:
|
||||
return await create_workspace_tool_executor(
|
||||
workspace_name=self.workspace_name,
|
||||
session_name=self.session_name,
|
||||
session_allowlist=self.session_allowlist,
|
||||
history_token_limit=settings.DIALECTIC.HISTORY_TOKEN_LIMIT,
|
||||
run_id=self._run_id,
|
||||
agent_type="workspace_dialectic",
|
||||
parent_category="dialectic",
|
||||
)
|
||||
|
||||
# Workspace chat shares the base "dialectic_chat" Langfuse trace name;
|
||||
# scope is distinguished by the agent_type/track_name below.
|
||||
|
||||
def _telemetry_context(self, track_name: str | None = None) -> LLMTelemetryContext:
|
||||
return LLMTelemetryContext(
|
||||
workspace_name=self.workspace_name,
|
||||
call_purpose="dialectic.answer",
|
||||
parent_category="dialectic",
|
||||
agent_type="workspace_dialectic",
|
||||
run_id=self._run_id,
|
||||
trace_id=self._run_id,
|
||||
span_id=self._run_id,
|
||||
session_id=self.session_id,
|
||||
peer_name="(workspace)",
|
||||
track_name=track_name or "Workspace Dialectic Agent",
|
||||
)
|
||||
|
|
@ -159,6 +159,17 @@ def _publish_embedding_event(
|
|||
logger.debug("Failed to emit EmbeddingCallCompletedEvent", exc_info=True)
|
||||
|
||||
|
||||
class EmbeddingTokenLimitError(ValueError):
|
||||
"""Raised when input text genuinely exceeds the model's token limit.
|
||||
|
||||
Subclasses ``ValueError`` so existing broad handlers keep working, while
|
||||
letting callers tell a real "content too long" condition apart from a
|
||||
transient provider or configuration failure (dimension mismatch, empty
|
||||
response, upstream error). Only the pre-flight token checks raise this;
|
||||
provider failures keep raising plain ``ValueError``.
|
||||
"""
|
||||
|
||||
|
||||
class BatchItem(NamedTuple):
|
||||
"""A single item in a batch with its metadata."""
|
||||
|
||||
|
|
@ -272,7 +283,7 @@ class _EmbeddingClient:
|
|||
token_count = len(self.encoding.encode(query))
|
||||
|
||||
if token_count > self.max_embedding_tokens:
|
||||
raise ValueError(
|
||||
raise EmbeddingTokenLimitError(
|
||||
f"Query exceeds maximum token limit of {self.max_embedding_tokens} tokens (got {token_count} tokens)"
|
||||
)
|
||||
|
||||
|
|
@ -358,8 +369,8 @@ class _EmbeddingClient:
|
|||
List of embedding vectors, one per input text (in order)
|
||||
|
||||
Raises:
|
||||
ValueError: If any text exceeds token limits and `on_oversize` is
|
||||
``"raise"``
|
||||
EmbeddingTokenLimitError: If any text exceeds token limits and
|
||||
`on_oversize` is ``"raise"``
|
||||
"""
|
||||
if not texts:
|
||||
return []
|
||||
|
|
@ -380,7 +391,7 @@ class _EmbeddingClient:
|
|||
tokens,
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
raise EmbeddingTokenLimitError(
|
||||
f"Text at index {idx} exceeds maximum token limit of "
|
||||
+ f"{self.max_embedding_tokens} tokens (got {len(token_ids)} tokens)"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -441,10 +441,19 @@ class OpenAIBackend:
|
|||
)
|
||||
|
||||
cache_creation, cache_read = extract_openai_cache_tokens(usage)
|
||||
# content_override=None means no override, not "force content to None"
|
||||
if content_override is not None:
|
||||
content: Any = content_override
|
||||
elif message.content is not None:
|
||||
content = message.content
|
||||
elif tool_calls:
|
||||
# Preserve null content on tool-call turns for history replay
|
||||
content = None
|
||||
else:
|
||||
content = ""
|
||||
|
||||
return CompletionResult(
|
||||
content=content_override
|
||||
if content_override is not None
|
||||
else (message.content or ""),
|
||||
content=content,
|
||||
input_tokens=usage.prompt_tokens if usage else 0,
|
||||
output_tokens=usage.completion_tokens if usage else 0,
|
||||
cache_creation_input_tokens=cache_creation,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@ from sentry_sdk.integrations.starlette import StarletteIntegration
|
|||
from src._version import HONCHO_VERSION
|
||||
from src.cache.client import close_cache, init_cache
|
||||
from src.config import settings
|
||||
from src.db import engine, register_db_query_instrumentation, request_context
|
||||
from src.db import (
|
||||
engine,
|
||||
register_db_connection_instrumentation,
|
||||
register_db_query_instrumentation,
|
||||
request_context,
|
||||
)
|
||||
from src.exceptions import HonchoException
|
||||
from src.routers import (
|
||||
conclusions,
|
||||
|
|
@ -109,6 +114,7 @@ async def lifespan(_: FastAPI):
|
|||
# Expose DB connection-pool stats for this API instance (no-op if metrics off)
|
||||
register_db_pool_collector("api")
|
||||
register_db_query_instrumentation("api")
|
||||
register_db_connection_instrumentation("api")
|
||||
|
||||
# region ai
|
||||
# Zero-init bounded-label counters so a missing series signals a broken scrape,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import logging
|
|||
from collections.abc import AsyncIterator
|
||||
from contextlib import suppress
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
|
@ -35,6 +34,7 @@ from src.utils.scopes import (
|
|||
is_scope_peer,
|
||||
is_scope_peer_name,
|
||||
validate_no_scope_peer_names,
|
||||
validate_scope_read_option,
|
||||
)
|
||||
from src.utils.search import search
|
||||
from src.utils.types import embedding_call_purpose
|
||||
|
|
@ -47,33 +47,6 @@ router = APIRouter(
|
|||
)
|
||||
|
||||
|
||||
def _validate_scope_option(
|
||||
*,
|
||||
filters: dict[str, Any] | None,
|
||||
session_id: str | None,
|
||||
jwt_params: JWTParams,
|
||||
) -> None:
|
||||
"""Enforce the v1 `scope` exclusions and auth rule (chat/representation).
|
||||
|
||||
`scope` is mutually exclusive with `filters` and `session_id` (422), and a
|
||||
scope's member sessions may exceed a peer's own membership, so scoped
|
||||
reads require a workspace- or admin-level key.
|
||||
|
||||
401 rather than 403: every other scope surface refuses a narrow key with 401
|
||||
— the `/scopes` router via `require_auth`, and the `scopes` field on session
|
||||
create — so a peer key would otherwise get two different codes for the same
|
||||
feature depending on which side of it was touched.
|
||||
"""
|
||||
if filters is not None:
|
||||
raise ValidationException("`scope` and `filters` are mutually exclusive")
|
||||
if session_id:
|
||||
raise ValidationException("`scope` and `session_id` are mutually exclusive")
|
||||
if jwt_params.p is not None:
|
||||
raise AuthenticationException(
|
||||
"`scope` requires a workspace- or admin-level key"
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_scope_option(
|
||||
workspace_id: str,
|
||||
scope: str | list[str],
|
||||
|
|
@ -95,16 +68,7 @@ async def _resolve_scope_option(
|
|||
)
|
||||
return scope_peer, None
|
||||
|
||||
scope_peers = await crud.resolve_scope_peers(scope_db, workspace_id, scope)
|
||||
union: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for scope_peer in scope_peers:
|
||||
for session_name in await get_peer_session_names(
|
||||
scope_db, workspace_id, scope_peer
|
||||
):
|
||||
if session_name not in seen:
|
||||
seen.add(session_name)
|
||||
union.append(session_name)
|
||||
union = await crud.resolve_scope_session_union(scope_db, workspace_id, scope)
|
||||
|
||||
if len(union) > MAX_SESSION_ALLOWLIST_ENTRIES:
|
||||
raise ValidationException(
|
||||
|
|
@ -316,7 +280,7 @@ async def chat(
|
|||
observer = peer_id
|
||||
scope_session_union: list[str] | None = None
|
||||
if options.scope is not None:
|
||||
_validate_scope_option(
|
||||
validate_scope_read_option(
|
||||
filters=options.filters,
|
||||
session_id=options.session_id,
|
||||
jwt_params=jwt_params,
|
||||
|
|
@ -506,7 +470,7 @@ async def get_representation(
|
|||
observer = peer_id
|
||||
scope_session_union: list[str] | None = None
|
||||
if options.scope is not None:
|
||||
_validate_scope_option(
|
||||
validate_scope_read_option(
|
||||
filters=options.filters,
|
||||
session_id=options.session_id,
|
||||
jwt_params=jwt_params,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
"""FastAPI routes for workspace resources and workspace-scoped operations."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlalchemy import apaginate
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud, models, schemas
|
||||
|
|
@ -12,8 +16,13 @@ from src.config import settings
|
|||
from src.crud.message import get_peer_session_names
|
||||
from src.dependencies import db, read_db, tracked_db
|
||||
from src.deriver.enqueue import enqueue_deletion, enqueue_dream
|
||||
from src.dialectic.chat import workspace_chat, workspace_chat_stream
|
||||
from src.exceptions import AuthenticationException, ValidationException
|
||||
from src.security import JWTParams, require_auth
|
||||
from src.telemetry import prometheus_metrics
|
||||
from src.utils.filter import MAX_SESSION_ALLOWLIST_ENTRIES
|
||||
from src.utils.schema_conversion import json_response_schema_to_pydantic
|
||||
from src.utils.scopes import validate_scope_read_option
|
||||
from src.utils.search import search
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -276,3 +285,92 @@ async def schedule_dream(
|
|||
observed,
|
||||
request.session_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{workspace_id}/chat",
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": schemas.DialecticResponse.model_json_schema()
|
||||
},
|
||||
"text/event-stream": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
async def chat(
|
||||
workspace_id: str = Path(...),
|
||||
options: schemas.WorkspaceChatOptions = Body(...),
|
||||
jwt_params: JWTParams = Depends(require_auth(workspace_name="workspace_id")),
|
||||
):
|
||||
"""Query the entire workspace using natural language.
|
||||
|
||||
Pass `scope` to restrict recall to the union of those scopes' member
|
||||
sessions. A scope with no member sessions recalls nothing (fail-closed).
|
||||
"""
|
||||
session_allowlist: list[str] | None = None
|
||||
if options.scope is not None:
|
||||
validate_scope_read_option(
|
||||
filters=None,
|
||||
session_id=options.session_id,
|
||||
jwt_params=jwt_params,
|
||||
)
|
||||
names = [options.scope] if isinstance(options.scope, str) else options.scope
|
||||
async with tracked_db(
|
||||
"workspaces.chat.resolve_scope", read_only=True
|
||||
) as scope_db:
|
||||
session_allowlist = await crud.resolve_scope_session_union(
|
||||
scope_db, workspace_id, names
|
||||
)
|
||||
if len(session_allowlist) > MAX_SESSION_ALLOWLIST_ENTRIES:
|
||||
raise ValidationException(
|
||||
"The scopes' combined membership exceeds the maximum of "
|
||||
+ f"{MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request"
|
||||
)
|
||||
|
||||
response_model: type[BaseModel] | None = None
|
||||
if options.response_format is not None:
|
||||
try:
|
||||
response_model = json_response_schema_to_pydantic(options.response_format)
|
||||
except ValueError as e:
|
||||
raise ValidationException(f"Invalid response_format: {e}") from None
|
||||
|
||||
if settings.METRICS.ENABLED:
|
||||
prometheus_metrics.record_dialectic_call(
|
||||
workspace_name=workspace_id,
|
||||
reasoning_level=options.reasoning_level,
|
||||
)
|
||||
|
||||
if options.stream:
|
||||
|
||||
async def format_sse_stream(chunks: AsyncIterator[str]) -> AsyncIterator[str]:
|
||||
"""Format chunks as SSE events."""
|
||||
async for chunk in chunks:
|
||||
yield f"data: {json.dumps({'delta': {'content': chunk}, 'done': False})}\n\n"
|
||||
yield f"data: {json.dumps({'done': True})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
format_sse_stream(
|
||||
workspace_chat_stream(
|
||||
workspace_name=workspace_id,
|
||||
session_name=options.session_id,
|
||||
query=options.query,
|
||||
reasoning_level=options.reasoning_level,
|
||||
response_model=response_model,
|
||||
session_allowlist=session_allowlist,
|
||||
)
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
response = await workspace_chat(
|
||||
workspace_name=workspace_id,
|
||||
session_name=options.session_id,
|
||||
query=options.query,
|
||||
reasoning_level=options.reasoning_level,
|
||||
response_model=response_model,
|
||||
session_allowlist=session_allowlist,
|
||||
)
|
||||
return schemas.DialecticResponse(content=response if response else None)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ from src.schemas.api import (
|
|||
WebhookEndpointCreate,
|
||||
Workspace,
|
||||
WorkspaceBase,
|
||||
WorkspaceChatOptions,
|
||||
WorkspaceCreate,
|
||||
WorkspaceGet,
|
||||
WorkspaceMessageSearchOptions,
|
||||
|
|
@ -114,6 +115,7 @@ __all__ = [
|
|||
"ConclusionQuery",
|
||||
"DialecticOptions",
|
||||
"DialecticResponse",
|
||||
"WorkspaceChatOptions",
|
||||
"DialecticStreamChunk",
|
||||
"DialecticStreamDelta",
|
||||
"Message",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,15 @@ def _sanitize_value(v: Any) -> Any:
|
|||
return v
|
||||
|
||||
|
||||
def _strip_nul(v: str) -> str:
|
||||
"""Strip NUL bytes from a string field (Postgres TEXT rejects \\x00)."""
|
||||
return v.replace("\x00", "")
|
||||
|
||||
|
||||
# Reusable annotation for query fields; composes with a per-field Field(...).
|
||||
NulStripped = AfterValidator(_strip_nul)
|
||||
|
||||
|
||||
def _check_metadata_limits(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
|
|
@ -718,7 +727,7 @@ class ConclusionBatchCreate(BaseModel):
|
|||
|
||||
|
||||
class MessageSearchOptions(BaseModel):
|
||||
query: Annotated[str, Field(..., description="Search query")]
|
||||
query: Annotated[str, Field(..., description="Search query"), NulStripped]
|
||||
filters: dict[str, Any] | None = Field(
|
||||
default=None, description="Filters to scope the search"
|
||||
)
|
||||
|
|
@ -729,11 +738,6 @@ class MessageSearchOptions(BaseModel):
|
|||
description="Number of results to return",
|
||||
)
|
||||
|
||||
@field_validator("query", mode="after")
|
||||
@classmethod
|
||||
def sanitize_query(cls, v: str) -> str:
|
||||
return v.replace("\x00", "")
|
||||
|
||||
|
||||
class WorkspaceMessageSearchOptions(MessageSearchOptions):
|
||||
"""Workspace-level message search options, extended with `scope`."""
|
||||
|
|
@ -786,7 +790,9 @@ class DialecticOptions(BaseModel):
|
|||
description="Optional peer to get the representation for, from the perspective of this peer",
|
||||
)
|
||||
query: Annotated[
|
||||
str, Field(min_length=1, max_length=10000, description="Dialectic API Prompt")
|
||||
str,
|
||||
Field(min_length=1, max_length=10000, description="Dialectic API Prompt"),
|
||||
NulStripped,
|
||||
]
|
||||
stream: bool = False
|
||||
reasoning_level: ReasoningLevel = Field(
|
||||
|
|
@ -804,10 +810,39 @@ class DialecticOptions(BaseModel):
|
|||
),
|
||||
)
|
||||
|
||||
@field_validator("query", mode="after")
|
||||
@classmethod
|
||||
def sanitize_query(cls, v: str) -> str:
|
||||
return v.replace("\x00", "")
|
||||
|
||||
class WorkspaceChatOptions(BaseModel):
|
||||
"""Options for workspace-level chat (no anchor peer; see DialecticOptions)."""
|
||||
|
||||
session_id: str | None = Field(
|
||||
None, description="Optional session to scope message tools to"
|
||||
)
|
||||
query: Annotated[
|
||||
str,
|
||||
Field(min_length=1, max_length=10000, description="Workspace chat prompt"),
|
||||
NulStripped,
|
||||
]
|
||||
stream: bool = False
|
||||
reasoning_level: ReasoningLevel = Field(
|
||||
default="low",
|
||||
description="Level of reasoning to apply: minimal, low, medium, high, or max",
|
||||
)
|
||||
response_format: dict[str, Any] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional JSON Schema (root type 'object') the response must conform"
|
||||
" to. When provided, `content` is a JSON string matching this schema."
|
||||
),
|
||||
)
|
||||
scope: _ScopeOption | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional (unprefixed) scope name(s) restricting recall to the "
|
||||
"union of the scopes' member sessions (explicit allowlist, "
|
||||
"fail-closed: an empty union recalls nothing). Mutually exclusive "
|
||||
"with `session_id`. Requires a workspace- or admin-level key."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DialecticResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -208,6 +208,27 @@ db_queries_in_flight_gauge = NamespacedGauge(
|
|||
["namespace", "instance_type"],
|
||||
)
|
||||
|
||||
# Physical DB connections, tracked via SQLAlchemy connection-lifecycle events
|
||||
# (see DBConnectionTracker in src/db.py) rather than the pool object, so they are
|
||||
# visible under EVERY pool class — including NullPool, whose pool holds no records
|
||||
# for the scrape-time db_pool_connections collector to read. Under QueuePool this
|
||||
# roughly equals db_pool_connections{checked_in} + {checked_out}; its unique value
|
||||
# is under NullPool, where that collector reads zero.
|
||||
db_connections_open_gauge = NamespacedGauge(
|
||||
"db_connections_open",
|
||||
"Physical DB connections currently open by this instance, across all pool "
|
||||
+ "classes (tracks concurrency of DB work under NullPool, pool occupancy "
|
||||
+ "under QueuePool)",
|
||||
["namespace", "instance_type"],
|
||||
)
|
||||
|
||||
db_connections_established_counter = NamespacedCounter(
|
||||
"db_connections_established",
|
||||
"Physical DB connections established since process start. Under NullPool, "
|
||||
+ "rate() approximates request rate (one connect per DB checkout)",
|
||||
["namespace", "instance_type"],
|
||||
)
|
||||
|
||||
|
||||
@final
|
||||
class PrometheusMetrics:
|
||||
|
|
@ -409,6 +430,11 @@ class PrometheusMetrics:
|
|||
"""Pre-create bounded-label counter children at 0 for this process, so an
|
||||
absent series means a broken scrape rather than "nothing happened".
|
||||
|
||||
Note: the DB-instrumentation metrics (db_queries_in_flight,
|
||||
db_connections_open, db_connections_established) are NOT initialized here —
|
||||
they zero-init via the pre-resolved labeled children in their register_db_*
|
||||
functions in src/db.py, so an auditor should not read them as forgotten.
|
||||
|
||||
Args:
|
||||
instance_type: "api" or "deriver" — selects the process-specific
|
||||
counters. Event-type and buffer metrics are initialized in both.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import asyncio
|
|||
import logging
|
||||
import weakref
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from typing import Any, cast
|
||||
|
||||
|
|
@ -13,7 +13,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from src import crud, models, schemas
|
||||
from src.config import settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.embedding_client import embedding_client
|
||||
from src.embedding_client import EmbeddingTokenLimitError, embedding_client
|
||||
from src.exceptions import ResourceNotFoundException
|
||||
from src.models import Document
|
||||
from src.schemas import ResolvedConfiguration
|
||||
from src.telemetry.events import (
|
||||
|
|
@ -291,6 +292,11 @@ def _safe_int(value: Any, default: int) -> int:
|
|||
return default
|
||||
|
||||
|
||||
def _bounded_int(value: Any, default: int, *, lo: int = 1, hi: int) -> int:
|
||||
"""Coerce a tool int into ``[lo, hi]``, falling back to ``default`` on bad input."""
|
||||
return max(lo, min(_safe_int(value, default), hi))
|
||||
|
||||
|
||||
# Module-level lock registry for thread-safe observation creation.
|
||||
# Keyed by (workspace_name, observer, observed) to ensure all tool executors
|
||||
# operating on the same data share the same lock.
|
||||
|
|
@ -785,6 +791,59 @@ TOOLS: dict[str, dict[str, Any]] = {
|
|||
"required": ["observation_id"],
|
||||
},
|
||||
},
|
||||
"search_memory_workspace": {
|
||||
"name": "search_memory",
|
||||
"description": "Search within a specific peer representation's memory using semantic similarity. You MUST specify observer and observed. To get a peer's global representation, set observer AND observed to the SAME peer name (this is where most information lives). Only use different observer/observed when seeking one peer's specific understanding of another.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"observer": {
|
||||
"type": "string",
|
||||
"description": "Name of the observer peer",
|
||||
},
|
||||
"observed": {
|
||||
"type": "string",
|
||||
"description": "Name of the observed peer",
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query text",
|
||||
},
|
||||
"top_k": {
|
||||
"type": "integer",
|
||||
"description": "(Optional) number of results to return (default: 20, max: 40)",
|
||||
"default": 20,
|
||||
},
|
||||
},
|
||||
"required": ["observer", "observed", "query"],
|
||||
},
|
||||
},
|
||||
"get_workspace_stats": {
|
||||
"name": "get_workspace_stats",
|
||||
"description": "Get workspace-level statistics — peer count, session count, message count, date range of messages — plus the most recently active peers with their message counts and last-active timestamps. Use this to orient yourself and discover which peers are most relevant.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
},
|
||||
"get_peer_card_by_name": {
|
||||
"name": "get_peer_card",
|
||||
"description": "Get the peer card for a specific peer relationship. Specify the observer and observed peer names.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"observer": {
|
||||
"type": "string",
|
||||
"description": "Name of the observer peer",
|
||||
},
|
||||
"observed": {
|
||||
"type": "string",
|
||||
"description": "Name of the observed peer",
|
||||
},
|
||||
},
|
||||
"required": ["observer", "observed"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Tools for the dialectic agent (analysis)
|
||||
|
|
@ -806,6 +865,31 @@ DIALECTIC_TOOLS_MINIMAL: list[dict[str, Any]] = [
|
|||
TOOLS["search_messages"],
|
||||
]
|
||||
|
||||
# Tools for the workspace-level dialectic agent. Observation search stays
|
||||
# pair-scoped (observer/observed are TOOL ARGUMENTS the agent must supply
|
||||
# after routing) -- matching both the (observer, observed) collection
|
||||
# ownership and the per-pair vector-store namespaces. Message tools are
|
||||
# workspace-flat and double as the routing signal (results carry peer_name).
|
||||
WORKSPACE_DIALECTIC_TOOLS: list[dict[str, Any]] = [
|
||||
TOOLS["get_workspace_stats"],
|
||||
TOOLS["search_memory_workspace"],
|
||||
TOOLS["search_messages"],
|
||||
TOOLS["get_observation_context"],
|
||||
TOOLS["grep_messages"],
|
||||
TOOLS["get_peer_card_by_name"],
|
||||
TOOLS["get_messages_by_date_range"],
|
||||
TOOLS["search_messages_temporal"],
|
||||
TOOLS["get_reasoning_chain"],
|
||||
]
|
||||
|
||||
# Reduced workspace loadout for reasoning_level="minimal" (token cost of the
|
||||
# tool definitions themselves), mirroring DIALECTIC_TOOLS_MINIMAL.
|
||||
WORKSPACE_TOOLS_MINIMAL: list[dict[str, Any]] = [
|
||||
TOOLS["get_workspace_stats"],
|
||||
TOOLS["search_memory_workspace"],
|
||||
TOOLS["search_messages"],
|
||||
]
|
||||
|
||||
# Tools for the dreamer agent (consolidation + peer card + deduplication)
|
||||
DREAMER_TOOLS: list[dict[str, Any]] = [
|
||||
# Preference extraction (should be called first)
|
||||
|
|
@ -1194,10 +1278,19 @@ async def get_observation_context(
|
|||
if not message_ids:
|
||||
return []
|
||||
|
||||
from src.crud.message import resolve_session_scope
|
||||
from src.crud.message import resolve_session_scope_clauses
|
||||
|
||||
allowed_session_names, deny = await resolve_session_scope(
|
||||
db, workspace_name, session_name, session_allowlist, observer
|
||||
# Scope as SQL rather than as a fetched name list. The scope is applied to
|
||||
# both the CTE and the outer select, so a materialized list would spend two
|
||||
# bind parameters per session the observer belongs to — enough sessions and
|
||||
# the statement exceeds the driver's 65535-parameter ceiling and cannot be
|
||||
# sent at all.
|
||||
scope_clauses, deny = resolve_session_scope_clauses(
|
||||
workspace_name,
|
||||
session_name,
|
||||
session_allowlist,
|
||||
observer,
|
||||
models.Message.session_name,
|
||||
)
|
||||
if deny:
|
||||
return []
|
||||
|
|
@ -1211,8 +1304,8 @@ async def get_observation_context(
|
|||
|
||||
if session_name:
|
||||
stmt = stmt.where(models.Message.session_name == session_name)
|
||||
elif allowed_session_names is not None:
|
||||
stmt = stmt.where(models.Message.session_name.in_(allowed_session_names))
|
||||
for clause in scope_clauses:
|
||||
stmt = stmt.where(clause)
|
||||
|
||||
target_seqs_cte = stmt.cte("target_seqs")
|
||||
|
||||
|
|
@ -1235,8 +1328,8 @@ async def get_observation_context(
|
|||
|
||||
if session_name:
|
||||
stmt = stmt.where(models.Message.session_name == session_name)
|
||||
elif allowed_session_names is not None:
|
||||
stmt = stmt.where(models.Message.session_name.in_(allowed_session_names))
|
||||
for clause in scope_clauses:
|
||||
stmt = stmt.where(clause)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
messages = list(result.scalars().all())
|
||||
|
|
@ -1795,7 +1888,7 @@ async def _handle_search_memory(
|
|||
"""Handle search_memory tool."""
|
||||
from src.utils.types import ToolResult
|
||||
|
||||
top_k = min(_safe_int(tool_input.get("top_k"), 20), 40)
|
||||
top_k = _bounded_int(tool_input.get("top_k"), 20, hi=40)
|
||||
query = tool_input["query"]
|
||||
try:
|
||||
with embedding_call_purpose(
|
||||
|
|
@ -1805,11 +1898,15 @@ async def _handle_search_memory(
|
|||
parent_category=ctx.parent_category,
|
||||
):
|
||||
query_embedding = await embedding_client.embed(query)
|
||||
except ValueError:
|
||||
except EmbeddingTokenLimitError:
|
||||
return (
|
||||
"ERROR: Query exceeds maximum token limit of "
|
||||
+ f"{settings.EMBEDDING.MAX_INPUT_TOKENS}. Please use a shorter query."
|
||||
)
|
||||
except ValueError as e:
|
||||
# Provider/config failure, not an oversized query. Keep returning a
|
||||
# string so the tool loop can continue, but don't blame the query.
|
||||
return f"ERROR: Embedding the query failed: {e}"
|
||||
|
||||
# Base telemetry metadata; results_count gets filled in below.
|
||||
search_meta: dict[str, Any] = {
|
||||
|
|
@ -1851,8 +1948,8 @@ async def _handle_search_memory(
|
|||
# here, we automatically search the message history for relevant
|
||||
# information.
|
||||
zero_hit_meta = {**search_meta, "results_count": 0}
|
||||
if ctx.agent_type == "dialectic":
|
||||
limit = min(_safe_int(tool_input.get("top_k"), 20), 20)
|
||||
if ctx.agent_type in ("dialectic", "workspace_dialectic"):
|
||||
limit = _bounded_int(tool_input.get("top_k"), 20, hi=20)
|
||||
message_output = None
|
||||
snippets = await crud.search_messages(
|
||||
workspace_name=ctx.workspace_name,
|
||||
|
|
@ -1903,7 +2000,7 @@ async def _handle_get_observation_context(
|
|||
workspace_name=ctx.workspace_name,
|
||||
session_name=ctx.session_name,
|
||||
message_ids=tool_input["message_ids"],
|
||||
observer=ctx.observer,
|
||||
observer=ctx.observer or None,
|
||||
session_allowlist=ctx.session_allowlist,
|
||||
)
|
||||
if not messages:
|
||||
|
|
@ -1929,7 +2026,7 @@ async def _handle_search_messages(
|
|||
from src.utils.types import ToolResult
|
||||
|
||||
query = tool_input["query"]
|
||||
limit = min(_safe_int(tool_input.get("limit"), 10), 20) # Cap at 20
|
||||
limit = _bounded_int(tool_input.get("limit"), 10, hi=20)
|
||||
# Pre-compute embedding outside DB session to avoid holding a connection
|
||||
# during the external API call (same pattern as _handle_search_memory).
|
||||
with embedding_call_purpose(
|
||||
|
|
@ -1946,7 +2043,7 @@ async def _handle_search_messages(
|
|||
limit=limit,
|
||||
context_window=2,
|
||||
embedding=query_embedding,
|
||||
observer=ctx.observer,
|
||||
observer=ctx.observer or None,
|
||||
session_allowlist=ctx.session_allowlist,
|
||||
)
|
||||
search_meta: dict[str, Any] = {
|
||||
|
|
@ -1972,10 +2069,8 @@ async def _handle_grep_messages(
|
|||
text = tool_input.get("text", "")
|
||||
if not text:
|
||||
return "ERROR: 'text' parameter is required"
|
||||
limit = min(_safe_int(tool_input.get("limit"), 10), 30) # Cap at 30
|
||||
context_window = min(
|
||||
_safe_int(tool_input.get("context_window"), 2), 2
|
||||
) # Cap context
|
||||
limit = _bounded_int(tool_input.get("limit"), 10, hi=30)
|
||||
context_window = _bounded_int(tool_input.get("context_window"), 2, lo=0, hi=2)
|
||||
|
||||
snippets = await crud.grep_messages(
|
||||
workspace_name=ctx.workspace_name,
|
||||
|
|
@ -1983,7 +2078,7 @@ async def _handle_grep_messages(
|
|||
text=text,
|
||||
limit=limit,
|
||||
context_window=context_window,
|
||||
observer=ctx.observer,
|
||||
observer=ctx.observer or None,
|
||||
session_allowlist=ctx.session_allowlist,
|
||||
)
|
||||
if not snippets:
|
||||
|
|
@ -2028,7 +2123,7 @@ async def _handle_get_messages_by_date_range(
|
|||
"""Handle get_messages_by_date_range tool."""
|
||||
after_date_str = tool_input.get("after_date")
|
||||
before_date_str = tool_input.get("before_date")
|
||||
limit = min(_safe_int(tool_input.get("limit"), 20), 20)
|
||||
limit = _bounded_int(tool_input.get("limit"), 20, hi=20)
|
||||
order = tool_input.get("order", "desc")
|
||||
|
||||
after_date = _parse_date(after_date_str, "after_date")
|
||||
|
|
@ -2048,7 +2143,7 @@ async def _handle_get_messages_by_date_range(
|
|||
before_date=before_date,
|
||||
limit=limit,
|
||||
order=order,
|
||||
observer=ctx.observer,
|
||||
observer=ctx.observer or None,
|
||||
session_allowlist=ctx.session_allowlist,
|
||||
)
|
||||
msg_count = len(messages)
|
||||
|
|
@ -2094,8 +2189,8 @@ async def _handle_search_messages_temporal(
|
|||
|
||||
after_date_str = tool_input.get("after_date")
|
||||
before_date_str = tool_input.get("before_date")
|
||||
limit = min(_safe_int(tool_input.get("limit"), 10), 10)
|
||||
context_window = min(_safe_int(tool_input.get("context_window"), 2), 2)
|
||||
limit = _bounded_int(tool_input.get("limit"), 10, hi=10)
|
||||
context_window = _bounded_int(tool_input.get("context_window"), 2, lo=0, hi=2)
|
||||
|
||||
after_date = _parse_date(after_date_str, "after_date")
|
||||
if isinstance(after_date, str):
|
||||
|
|
@ -2124,7 +2219,7 @@ async def _handle_search_messages_temporal(
|
|||
context_window=context_window,
|
||||
session_allowlist=ctx.session_allowlist,
|
||||
embedding=query_embedding,
|
||||
observer=ctx.observer,
|
||||
observer=ctx.observer or None,
|
||||
)
|
||||
date_filter: list[str] = []
|
||||
if after_date_str:
|
||||
|
|
@ -2165,7 +2260,7 @@ async def _handle_get_recent_observations(
|
|||
workspace_name=ctx.workspace_name,
|
||||
observer=ctx.observer,
|
||||
observed=ctx.observed,
|
||||
limit=min(_safe_int(tool_input.get("limit"), 10), 100),
|
||||
limit=_bounded_int(tool_input.get("limit"), 10, hi=100),
|
||||
session_name=ctx.session_name if session_only else None,
|
||||
)
|
||||
representation = Representation.from_documents(documents)
|
||||
|
|
@ -2191,7 +2286,7 @@ async def _handle_get_most_derived_observations(
|
|||
workspace_name=ctx.workspace_name,
|
||||
observer=ctx.observer,
|
||||
observed=ctx.observed,
|
||||
limit=min(_safe_int(tool_input.get("limit"), 10), 100),
|
||||
limit=_bounded_int(tool_input.get("limit"), 10, hi=100),
|
||||
)
|
||||
representation = Representation.from_documents(documents)
|
||||
total_count = representation.len()
|
||||
|
|
@ -2229,6 +2324,16 @@ async def _handle_get_session_summary(
|
|||
async def _handle_get_peer_card(ctx: ToolContext, tool_input: dict[str, Any]) -> str:
|
||||
"""Handle get_peer_card tool."""
|
||||
_ = tool_input
|
||||
# A peer card lives in Peer.internal_metadata as a single cross-session
|
||||
# aggregate, so it carries no session attribution and cannot be filtered
|
||||
# to an allowlist. Fail closed rather than leak facts derived from
|
||||
# out-of-scope sessions, the same rule get_reasoning_chain follows.
|
||||
# No-op for agents that never set an allowlist (dreamer, pair dialectic).
|
||||
if ctx.session_allowlist is not None:
|
||||
return (
|
||||
"Peer cards are unavailable for session-scoped queries. "
|
||||
"Use search_memory instead."
|
||||
)
|
||||
async with tracked_db("tool.get_peer_card", read_only=True) as db:
|
||||
peer_card = await crud.get_peer_card(
|
||||
db,
|
||||
|
|
@ -2513,6 +2618,7 @@ async def create_tool_executor(
|
|||
agent_type: str | None = None,
|
||||
parent_category: str | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
handler_resolver: Callable[[str], Any] | None = None,
|
||||
) -> Callable[[str, dict[str, Any]], Any]:
|
||||
"""
|
||||
Create a unified tool executor function for all agent operations.
|
||||
|
|
@ -2535,6 +2641,11 @@ async def create_tool_executor(
|
|||
run_id: Optional run ID for telemetry correlation
|
||||
agent_type: Optional agent type for telemetry (dialectic, deriver, dreamer)
|
||||
parent_category: Optional parent category for CloudEvents
|
||||
session_allowlist: Optional list of session names message tools are
|
||||
restricted to (None means no restriction)
|
||||
handler_resolver: Optional callback that replaces the default
|
||||
handler-table lookup for resolving tool names to handlers.
|
||||
Returning None takes the "Unknown tool" path.
|
||||
|
||||
Returns:
|
||||
An async callable that executes tools with the captured context
|
||||
|
|
@ -2598,7 +2709,7 @@ async def create_tool_executor(
|
|||
tool_obs = _begin_tool_observation(tool_name, tool_input)
|
||||
|
||||
try:
|
||||
handler = _TOOL_HANDLERS.get(tool_name)
|
||||
handler = (handler_resolver or _TOOL_HANDLERS.get)(tool_name)
|
||||
if handler:
|
||||
handler_result = await handler(ctx, tool_input)
|
||||
# Handlers return either a plain str (existing contract) or a
|
||||
|
|
@ -2796,3 +2907,181 @@ def _estimate_tokens_safe(text: str | None) -> int | None:
|
|||
if not text:
|
||||
return None
|
||||
return _estimate_tokens(text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspace-level tool handlers (workspace chat)
|
||||
#
|
||||
# The workspace agent is not bound to an (observer, observed) pair. Handlers
|
||||
# that need a pair take it from tool_input (the agent routes first, then
|
||||
# supplies the pair); the rest are workspace-scoped reads. Message-search
|
||||
# fallthrough handlers run with observer="" and normalize it to None at the
|
||||
# crud boundary (`ctx.observer or None`) -- None means "no perspective
|
||||
# scoping", which is correct for a workspace-level read. The empty string
|
||||
# must never reach resolve_session_scope: it would be looked up as a real
|
||||
# peer with no session memberships and deny all results.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _handle_search_memory_workspace(
|
||||
ctx: ToolContext, tool_input: dict[str, Any]
|
||||
) -> "str | ToolResult":
|
||||
"""Pair-scoped observation search; the pair comes from tool arguments."""
|
||||
observer = tool_input.get("observer", "")
|
||||
observed = tool_input.get("observed", "")
|
||||
if not observer or not observed:
|
||||
return (
|
||||
"ERROR: 'observer' and 'observed' are required. For a peer's "
|
||||
"global representation set both to the SAME peer name."
|
||||
)
|
||||
pair_ctx = replace(ctx, observer=observer, observed=observed)
|
||||
result = await _handle_search_memory(pair_ctx, tool_input)
|
||||
# Attribute the pair in the output — the workspace agent may query
|
||||
# several pairs in one turn and must not conflate their results.
|
||||
if isinstance(result, ToolResult):
|
||||
return replace(result, content=f"[{observer}->{observed}]\n{result.content}")
|
||||
return f"[{observer}->{observed}]\n{result}"
|
||||
|
||||
|
||||
async def _handle_get_peer_card_by_name(
|
||||
ctx: ToolContext, tool_input: dict[str, Any]
|
||||
) -> str:
|
||||
"""get_peer_card with the pair taken from tool arguments."""
|
||||
observer = tool_input.get("observer", "")
|
||||
observed = tool_input.get("observed", "")
|
||||
if not observer or not observed:
|
||||
return "ERROR: 'observer' and 'observed' are required parameters"
|
||||
pair_ctx = replace(ctx, observer=observer, observed=observed)
|
||||
try:
|
||||
return await _handle_get_peer_card(pair_ctx, tool_input)
|
||||
except ResourceNotFoundException:
|
||||
# The workspace agent names peers from its own routing, so guessing a
|
||||
# peer that doesn't exist is an expected turn, not a fault. Answer the
|
||||
# model instead of letting the executor log it as an unexpected error.
|
||||
return f"No peer named '{observer}' exists in this workspace"
|
||||
|
||||
|
||||
# Peers listed by get_workspace_stats. Fixed rather than a tool argument:
|
||||
# folding active peers into stats keeps the tool zero-arg (one discovery
|
||||
# round instead of two); deeper discovery goes through search_messages.
|
||||
_STATS_ACTIVE_PEERS = 10
|
||||
|
||||
|
||||
# Peer-card facts listed per peer when cards are supplied.
|
||||
_STATS_CARD_FACTS = 8
|
||||
|
||||
|
||||
def format_workspace_stats(
|
||||
stats: "crud.WorkspaceStats",
|
||||
peers: "Sequence[crud.ActivePeer]",
|
||||
cards: dict[str, list[str]] | None = None,
|
||||
) -> str:
|
||||
"""Render workspace counts and most-active peers as prompt-ready lines.
|
||||
|
||||
Shared by the get_workspace_stats tool and WorkspaceDialecticAgent's
|
||||
routing prefetch; the prefetch passes ``cards`` to nest each peer's
|
||||
known biographical facts under it.
|
||||
"""
|
||||
lines = [
|
||||
f"Peers: {stats.peer_count}",
|
||||
f"Sessions: {stats.session_count}",
|
||||
f"Messages: {stats.message_count}",
|
||||
]
|
||||
if stats.oldest_message_at and stats.newest_message_at:
|
||||
lines.append(
|
||||
f"Date range: {stats.oldest_message_at:%Y-%m-%d} to {stats.newest_message_at:%Y-%m-%d}"
|
||||
)
|
||||
if peers:
|
||||
lines.append("")
|
||||
lines.append(f"Most active peers (top {len(peers)}):")
|
||||
for peer in peers:
|
||||
last_active = (
|
||||
f", last active {peer.last_message_at:%Y-%m-%d}"
|
||||
if peer.last_message_at
|
||||
else ""
|
||||
)
|
||||
lines.append(f"- {peer.name} ({peer.message_count} messages{last_active})")
|
||||
for fact in (cards or {}).get(peer.name, [])[:_STATS_CARD_FACTS]:
|
||||
lines.append(f" - {fact}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _handle_get_workspace_stats(
|
||||
ctx: ToolContext, tool_input: dict[str, Any]
|
||||
) -> str:
|
||||
"""Workspace-level counts, message date range, and most active peers."""
|
||||
_ = tool_input
|
||||
async with tracked_db("workspace_tool.get_workspace_stats", read_only=True) as db:
|
||||
stats = await crud.get_workspace_stats(
|
||||
db, ctx.workspace_name, session_names=ctx.session_allowlist
|
||||
)
|
||||
peers = await crud.get_active_peers(
|
||||
db,
|
||||
ctx.workspace_name,
|
||||
limit=_STATS_ACTIVE_PEERS,
|
||||
session_names=ctx.session_allowlist,
|
||||
)
|
||||
return "Workspace stats:\n" + format_workspace_stats(stats, peers)
|
||||
|
||||
|
||||
# Dispatch table consulted before _TOOL_HANDLERS by the workspace executor.
|
||||
_WORKSPACE_TOOL_HANDLERS: dict[str, Callable[[ToolContext, dict[str, Any]], Any]] = {
|
||||
"search_memory": _handle_search_memory_workspace,
|
||||
"get_workspace_stats": _handle_get_workspace_stats,
|
||||
"get_peer_card": _handle_get_peer_card_by_name,
|
||||
"get_reasoning_chain": _handle_get_reasoning_chain, # already workspace-scoped
|
||||
}
|
||||
|
||||
# Standard handlers that are safe with an empty observer/observed sentinel
|
||||
# (they only read messages, treating observer="" as unscoped visibility).
|
||||
_WORKSPACE_SAFE_FALLTHROUGH_TOOLS: frozenset[str] = frozenset(
|
||||
{
|
||||
"get_observation_context",
|
||||
"search_messages",
|
||||
"grep_messages",
|
||||
"get_messages_by_date_range",
|
||||
"search_messages_temporal",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _workspace_handler_resolver(tool_name: str) -> Any:
|
||||
handler = _WORKSPACE_TOOL_HANDLERS.get(tool_name)
|
||||
if handler is not None:
|
||||
return handler
|
||||
if tool_name in _WORKSPACE_SAFE_FALLTHROUGH_TOOLS:
|
||||
return _TOOL_HANDLERS.get(tool_name)
|
||||
return None
|
||||
|
||||
|
||||
async def create_workspace_tool_executor(
|
||||
workspace_name: str,
|
||||
session_name: str | None = None,
|
||||
session_allowlist: list[str] | None = None,
|
||||
history_token_limit: int = 8192,
|
||||
run_id: str | None = None,
|
||||
agent_type: str | None = None,
|
||||
parent_category: str | None = None,
|
||||
) -> Callable[[str, dict[str, Any]], Any]:
|
||||
"""Tool executor for workspace-level operations (no bound peer pair).
|
||||
|
||||
Reuses create_tool_executor's telemetry/error plumbing via the
|
||||
handler_resolver seam. observer/observed are empty-string sentinels only
|
||||
ever seen by handlers in _WORKSPACE_SAFE_FALLTHROUGH_TOOLS, which
|
||||
normalize them to None before hitting crud (None means "no perspective
|
||||
scoping"; an empty string would read as a real peer with no sessions and
|
||||
deny everything).
|
||||
"""
|
||||
return await create_tool_executor(
|
||||
workspace_name=workspace_name,
|
||||
observer="",
|
||||
observed="",
|
||||
session_name=session_name,
|
||||
session_allowlist=session_allowlist,
|
||||
include_observation_ids=True,
|
||||
history_token_limit=history_token_limit,
|
||||
run_id=run_id,
|
||||
agent_type=agent_type,
|
||||
parent_category=parent_category,
|
||||
handler_resolver=_workspace_handler_resolver,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -689,7 +689,9 @@ def _build_comparison_condition(
|
|||
"lte": lambda a, v: a <= v,
|
||||
"gt": lambda a, v: a > v,
|
||||
"lt": lambda a, v: a < v,
|
||||
"ne": lambda a, v: a != v,
|
||||
# IS DISTINCT FROM, not <>: (metadata ->> key) is NULL for an
|
||||
# absent key, and `NULL <> v` is NULL, so <> drops those rows.
|
||||
"ne": lambda a, v: a.is_distinct_from(v),
|
||||
}
|
||||
return operator_map[operator](safe_accessor, safe_value)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ carries a look-alike ``configuration``, is not a scope.
|
|||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from src.exceptions import ValidationException
|
||||
from src.exceptions import AuthenticationException, ValidationException
|
||||
from src.security import JWTParams
|
||||
|
||||
# Reserved peer-name prefix for scope peers. User-created peers may not use it.
|
||||
#
|
||||
|
|
@ -87,3 +88,20 @@ def validate_no_scope_peer_names(names: Iterable[str], *, action: str) -> None:
|
|||
f"Peer name(s) {offenders} use the reserved scope prefix "
|
||||
+ f"'{SCOPE_PEER_PREFIX}'. {action}"
|
||||
)
|
||||
|
||||
|
||||
def validate_scope_read_option(
|
||||
*,
|
||||
filters: dict[str, Any] | None,
|
||||
session_id: str | None,
|
||||
jwt_params: JWTParams,
|
||||
) -> None:
|
||||
"""Refuse `scope` combined with `filters`/`session_id`, or a peer-scoped key."""
|
||||
if filters is not None:
|
||||
raise ValidationException("`scope` and `filters` are mutually exclusive")
|
||||
if session_id:
|
||||
raise ValidationException("`scope` and `session_id` are mutually exclusive")
|
||||
if jwt_params.p is not None:
|
||||
raise AuthenticationException(
|
||||
"`scope` requires a workspace- or admin-level key"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from src import models
|
||||
from src.config import settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.embedding_client import embedding_client
|
||||
from src.embedding_client import EmbeddingTokenLimitError, embedding_client
|
||||
from src.exceptions import ValidationException
|
||||
from src.models import session_peers_table
|
||||
from src.telemetry.events import EmbeddingCallPurpose
|
||||
|
|
@ -82,6 +82,9 @@ async def query_external_vector_message_ids(
|
|||
filters: dict[str, Any] | None = None,
|
||||
) -> list[str]:
|
||||
"""Query the external vector store and return ordered message IDs."""
|
||||
if limit <= 0:
|
||||
return []
|
||||
|
||||
external_vector_store = get_external_vector_store()
|
||||
if external_vector_store is None:
|
||||
return []
|
||||
|
|
@ -388,7 +391,7 @@ async def search(
|
|||
parent_category="api",
|
||||
):
|
||||
query_embedding = await embedding_client.embed(query)
|
||||
except ValueError as e:
|
||||
except EmbeddingTokenLimitError as e:
|
||||
raise ValidationException(
|
||||
f"Query exceeds maximum token limit of {settings.EMBEDDING.MAX_INPUT_TOKENS}."
|
||||
) from e
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue