pr_01m47d15m3e54sn21z27rpy5n9/crates/contracts/src/capture.rs

275 lines9,639 bytesCodeBlame
1//! Memory that fills itself. Kept by the work service, beside memory.
2//!
3//! What agents and people learn arrives as a **candidate**: from what an
4//! agent reports learning at the end of a run, a person's correction in a
5//! review of an agent's pull request, a merged pull request's decision, or
6//! a project's own docs and manifests. A candidate is given to no agent
7//! until it is **kept**: by a member in the Review queue, or by g1t when
8//! two independent sources say the same thing, or a doc says it with high
9//! confidence ([`promotes`]). Nothing that looks like a secret is stored.
10//!
11//! Each `*Args` struct is the argument of the method of the same name,
12//! served at `POST /rpc/<method>`.
13
14use serde::{Deserialize, Serialize};
15
16use crate::agents::{Memory, MemoryKind, MemoryScope, MemoryStatus};
17use crate::repos::RepoPath;
18use crate::{User, Viewer};
19
20/// The confidence at or above which a doc's word is kept without review.
21pub const DOC_CONFIDENCE: f64 = 0.85;
22/// How many independent sources keep a candidate without review.
23pub const INDEPENDENT_SOURCES: usize = 2;
24/// The most items one capture takes.
25pub const MAX_CAPTURE: usize = 50;
26/// The most an agent reports learning in one run.
27pub const MAX_LEARNED: usize = 8;
28/// The longest piece of evidence kept, in characters.
29pub const MAX_EVIDENCE_CHARS: usize = 400;
30
31/// Where a captured memory came from.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum CaptureSource {
35 /// An agent's run, reporting what it learned.
36 Run,
37 /// A person's review of an agent's pull request.
38 Review,
39 /// A merged pull request.
40 Pr,
41 /// A project's README, AGENTS.md, docs or manifests.
42 Doc,
43 /// Written by a person.
44 Manual,
45}
46
47impl CaptureSource {
48 pub fn as_str(self) -> &'static str {
49 match self {
50 CaptureSource::Run => "run",
51 CaptureSource::Review => "review",
52 CaptureSource::Pr => "pr",
53 CaptureSource::Doc => "doc",
54 CaptureSource::Manual => "manual",
55 }
56 }
57}
58
59/// Whether a candidate is kept without anyone reviewing it: said by
60/// `sources` independent sources, or by a doc with `confidence` at or above
61/// [`DOC_CONFIDENCE`].
62pub fn promotes(sources: usize, kind: CaptureSource, confidence: Option<f64>) -> bool {
63 sources >= INDEPENDENT_SOURCES
64 || (kind == CaptureSource::Doc && confidence.is_some_and(|c| c >= DOC_CONFIDENCE))
65}
66
67/// A memory's text folded for comparing: lowercase words and digits, one
68/// space apart, so wording that differs only in case, punctuation or
69/// spacing is the same memory.
70pub fn fingerprint(text: &str) -> String {
71 text.to_lowercase()
72 .split(|c: char| !c.is_alphanumeric())
73 .filter(|word| !word.is_empty())
74 .collect::<Vec<_>>()
75 .join(" ")
76}
77
78/// One thing learned, as a source reports it.
79#[derive(Clone, Debug, Serialize, Deserialize)]
80#[serde(rename_all = "camelCase")]
81pub struct CaptureItem {
82 pub scope: MemoryScope,
83 /// For a project's memory: its repository's id.
84 #[serde(default)]
85 pub repo_id: Option<String>,
86 #[serde(default)]
87 pub kind: MemoryKind,
88 pub text: String,
89 /// 0 to 1.
90 #[serde(default)]
91 pub confidence: Option<f64>,
92 pub source: CaptureSource,
93 /// What it came from: `run:<id>`, `comment:<id>`, `pull:<repo id>#<n>`,
94 /// `doc:<repo id>:<path>`. Two items with the same reference are one
95 /// source, however often they arrive.
96 pub reference: String,
97 /// What it was learned from, quoted.
98 #[serde(default)]
99 pub evidence: Option<String>,
100 /// The pull request it was learned on.
101 #[serde(default)]
102 pub number: Option<u32>,
103 /// The agent run it was learned in.
104 #[serde(default)]
105 pub run_id: Option<String>,
106}
107
108/// `capture_memories`: candidates from a service (the context service's
109/// backfill and doc reading). Called by services only. Returns `Captured`.
110#[derive(Debug, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct CaptureMemoriesArgs {
113 /// The workspace's slug.
114 pub workspace: String,
115 pub items: Vec<CaptureItem>,
116 /// Who it is recorded as written by, such as `g1t`.
117 #[serde(default)]
118 pub by: Option<String>,
119}
120
121#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct Captured {
124 /// New candidates.
125 pub added: u32,
126 /// Ones that matched a memory already there, as another sighting.
127 pub merged: u32,
128 /// Ones kept by the promotion rule, new or merged.
129 pub kept: u32,
130 /// Ones refused: empty, too long, or holding something like a secret.
131 pub refused: u32,
132}
133
134/// What an agent says it learned, as the harness reports it.
135#[derive(Clone, Debug, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct LearnedItem {
138 /// `fact`, `convention`, `decision` or `gotcha`.
139 #[serde(default)]
140 pub kind: Option<String>,
141 /// `project` or `workspace`.
142 #[serde(default)]
143 pub scope: Option<String>,
144 pub text: String,
145 /// What showed it: a command's output, a file, a failing test.
146 #[serde(default)]
147 pub evidence: Option<String>,
148}
149
150/// `report_learned`: a sandbox reporting what its agent learned, with the
151/// run's token. Each item arrives as a candidate from the run. Returns
152/// `Outcome<Captured>`.
153#[derive(Debug, Default, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase", default)]
155pub struct ReportLearnedArgs {
156 pub run_id: String,
157 pub token: String,
158 pub items: Vec<LearnedItem>,
159}
160
161/// `list_candidates`: memory waiting for review in a workspace and, with
162/// `repo`, only that project's. Members only. Returns `Outcome<Vec<Memory>>`.
163#[derive(Debug, Serialize, Deserialize)]
164pub struct ListCandidatesArgs {
165 pub viewer: Viewer,
166 pub workspace: String,
167 #[serde(default)]
168 pub repo: Option<RepoPath>,
169}
170
171#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173pub enum ReviewDecision {
174 Keep,
175 Dismiss,
176}
177
178/// `review_memory`: a member keeps a candidate, edited or as it is, or
179/// dismisses it. A dismissed memory is not suggested again from the same
180/// wording. Returns `Outcome<Memory>`.
181#[derive(Debug, Serialize, Deserialize)]
182pub struct ReviewMemoryArgs {
183 pub actor: User,
184 pub workspace: String,
185 pub id: String,
186 pub decision: ReviewDecision,
187 #[serde(default)]
188 pub text: Option<String>,
189 #[serde(default)]
190 pub kind: Option<MemoryKind>,
191}
192
193/// `memories_by_id`: memories as the context service indexes them, in any
194/// status, so a dismissed one can be taken out of search. Services only.
195/// Returns `Vec<Memory>`.
196#[derive(Debug, Serialize, Deserialize)]
197pub struct MemoriesByIdArgs {
198 pub workspace: String,
199 pub ids: Vec<String>,
200}
201
202/// `search_memories`: kept memories in a workspace whose text has every
203/// word of `query`, pinned first; with `repo_ids`, only the workspace's own
204/// and those projects'. Services only: the caller has decided the viewer
205/// may read the workspace's memory. Returns `Vec<Memory>`.
206#[derive(Debug, Serialize, Deserialize)]
207#[serde(rename_all = "camelCase")]
208pub struct SearchMemoriesArgs {
209 pub workspace: String,
210 #[serde(default)]
211 pub query: Option<String>,
212 #[serde(default)]
213 pub repo_ids: Option<Vec<String>>,
214 #[serde(default)]
215 pub status: Option<MemoryStatus>,
216 #[serde(default)]
217 pub limit: Option<u32>,
218}
219
220/// `seed_from_pulls`: decision candidates from a repository's last merged
221/// pull requests, and convention candidates from people's reviews of its
222/// agents' ones. Services only (the backfill). Returns `Captured`.
223#[derive(Debug, Serialize, Deserialize)]
224#[serde(rename_all = "camelCase")]
225pub struct SeedFromPullsArgs {
226 pub repo_id: String,
227 /// At most 50; 20 if not given.
228 #[serde(default)]
229 pub limit: Option<u32>,
230}
231
232/// Memories, newest first, for listing a review queue.
233pub type Candidates = Vec<Memory>;
234
235/// The `memory.changed` event: a memory was added, changed, reviewed or
236/// forgotten. Carries no text; a subscriber asks for the memory by id. Its
237/// `repo_id` is the project's for a project's memory, none for the
238/// workspace's.
239#[derive(Debug, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub struct MemoryChanged {
242 pub memory_id: String,
243 pub workspace: String,
244 /// `candidate`, `kept` or `dismissed`; `deleted` once forgotten.
245 pub status: String,
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn two_independent_sources_keep_a_candidate() {
254 assert!(!promotes(1, CaptureSource::Run, Some(0.9)));
255 assert!(promotes(2, CaptureSource::Run, None));
256 assert!(promotes(3, CaptureSource::Review, Some(0.1)));
257 }
258
259 #[test]
260 fn a_confident_doc_is_kept_and_a_doubtful_one_waits() {
261 assert!(promotes(1, CaptureSource::Doc, Some(0.9)));
262 assert!(promotes(1, CaptureSource::Doc, Some(DOC_CONFIDENCE)));
263 assert!(!promotes(1, CaptureSource::Doc, Some(0.6)));
264 assert!(!promotes(1, CaptureSource::Doc, None));
265 // Only a doc's confidence counts on its own.
266 assert!(!promotes(1, CaptureSource::Pr, Some(1.0)));
267 }
268
269 #[test]
270 fn fingerprints_ignore_case_punctuation_and_spacing() {
271 assert_eq!(fingerprint("Use pnpm, never npm!"), "use pnpm never npm");
272 assert_eq!(fingerprint("use pnpm never NPM"), fingerprint("Use pnpm; never npm."));
273 assert_ne!(fingerprint("use pnpm"), fingerprint("use npm"));
274 }
275}