Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

g1t's skills reach Anthropic accounts again when they change. Sessions g1t starts uploaded work-on-issue, coordinate and fix-integration to the account once and never again, so an account kept the first SKILL.md it got however the skill changed. managed_agents.skills now keeps, for each, its id, the version g1t uploaded and the SHA-256 of what it holds; when g1t's copy differs, the next session uploads it as a new version (POST /v1/skills/{id}/versions), and makes it afresh if someone deleted it on the account. The agent names g1t's skills at the version g1t uploaded, not "latest", so a new version changes the agent's body and config_hash updates the agent; the account's own skills stay at their latest. An upload that fails leaves the old version in use and is tried again at the next session. Rows stored before this (an id alone) are uploaded again once. The sessions guide says so.

Checked:
cargo test -p g1t-integrations --lib (73, new: stored skills old and new, pinned and latest references, the uploaded version from a version or a new skill), its wasm32 check.
syntaqxcommitted Parente446ed4Browse files
2 files+108−180/2 viewed
+3−2
289289 placeholder that is swapped for the token on its way to g1t. It is never
290290 in the prompt, and the model never sees it. The session has g1t's skills
291291 (work-on-issue, coordinate and fix-integration, uploaded to the account
292− the first time) next to the account's own custom skills, up to 20 in
293− all. It may hand parts of the work to copies of itself; each is recorded
292+ the first time, and as a new version whenever g1t changes them; the
293+ agent uses the version g1t uploaded) next to the account's own custom
294+ skills, at their latest version, up to 20 in all. It may hand parts of the work to copies of itself; each is recorded
294295 as a subagent.
295296 - **Without one**, or with `handoff: true`, nothing starts: you get
296297 `command`, a Claude Code command line to run in your working copy with
+105−16
204204 json!({ "events": [{ "type": "user.interrupt" }] })
205205 }
206206
207+/// One of g1t's skills on an account: its id there, the version g1t last
208+/// uploaded, and the SHA-256 of what that version holds, so a changed
209+/// SKILL.md is uploaded again as a new version.
210+#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, Deserialize)]
211+pub struct OwnSkill {
212+ pub id: String,
213+ #[serde(default, skip_serializing_if = "Option::is_none")]
214+ pub version: Option<String>,
215+ #[serde(default, skip_serializing_if = "Option::is_none")]
216+ pub sha256: Option<String>,
217+}
218+
219+/// g1t's skills as stored for an agent, by name. Rows written before
220+/// versions were kept hold the id alone, and are uploaded again once.
221+pub fn own_skills(stored: &str) -> BTreeMap<String, OwnSkill> {
222+ let parsed: BTreeMap<String, Value> = serde_json::from_str(stored).unwrap_or_default();
223+ parsed
224+ .into_iter()
225+ .filter_map(|(name, value)| {
226+ let skill = match value {
227+ Value::String(id) => OwnSkill { id, ..OwnSkill::default() },
228+ other => serde_json::from_value(other).ok()?,
229+ };
230+ (!skill.id.is_empty()).then_some((name, skill))
231+ })
232+ .collect()
233+}
234+
235+/// How an agent names a skill: g1t's own at the version it uploaded, so a
236+/// new version changes the agent and reaches it; the account's at its latest.
237+pub fn skill_ref(id: &str, version: Option<&str>) -> Value {
238+ json!({ "type": "custom", "skill_id": id, "version": version.unwrap_or("latest") })
239+}
240+
241+/// The version an upload made: a new version's `version`, a new skill's
242+/// `latest_version`, as a string whichever way it is written.
243+fn uploaded_version(answer: &Value) -> Option<String> {
244+ [&answer["version"], &answer["latest_version"]].into_iter().find_map(|value| match value {
245+ Value::String(text) if !text.is_empty() => Some(text.clone()),
246+ Value::Number(number) => Some(number.to_string()),
247+ _ => None,
248+ })
249+}
250+
207251 /// A request body of files, as `multipart/form-data` with `boundary`.
208252 pub fn multipart(boundary: &str, files: &[(String, String)]) -> String {
209253 let mut body = String::new();
676720 Ok(Outcome::Ok(self.anthropic_connection(&workspace).await?.0))
677721 }
678722
679− /// g1t's skills on the account, uploaded the first time, and the
680− /// account's own custom skills: what the agent is given, at most 20.
681− async fn agent_skills(&self, key: &str, known: &BTreeMap<String, String>) -> Result<(Vec<Value>, BTreeMap<String, String>)> {
723+ /// Uploads a skill's SKILL.md: a new skill, or a new version of `id`.
724+ async fn upload_skill(&self, key: &str, id: Option<&str>, name: &str, content: &str) -> Result<http::Answer> {
725+ let boundary = format!("g1t{}", g1t_secrets::random_hex(12));
726+ let body = multipart(&boundary, &[(format!("{name}/SKILL.md"), content.to_owned())]);
727+ let content_type = format!("multipart/form-data; boundary={boundary}");
728+ let headers = [("x-api-key", key), ("anthropic-version", VERSION), ("content-type", content_type.as_str())];
729+ let path = match id {
730+ Some(id) => format!("{API}/v1/skills/{id}/versions"),
731+ None => format!("{API}/v1/skills"),
732+ };
733+ http::send(Method::Post, &path, &headers, Some(body)).await
734+ }
735+
736+ /// g1t's skills on the account, uploaded the first time and again as a
737+ /// new version whenever g1t's copy changes, and the account's own
738+ /// custom skills: what the agent is given, at most 20.
739+ async fn agent_skills(&self, key: &str, known: &BTreeMap<String, OwnSkill>) -> Result<(Vec<Value>, BTreeMap<String, OwnSkill>)> {
682740 let mut ours = known.clone();
683741 for (name, content) in G1T_SKILLS {
684− if ours.contains_key(name) {
742+ let sha256 = g1t_secrets::sha256_hex(content);
743+ let held = ours.get(name).cloned();
744+ if held.as_ref().is_some_and(|skill| skill.sha256.as_deref() == Some(sha256.as_str())) {
685745 continue;
686746 }
687− let boundary = format!("g1t{}", g1t_secrets::random_hex(12));
688− let body = multipart(&boundary, &[(format!("{name}/SKILL.md"), content.to_owned())]);
689− let content_type = format!("multipart/form-data; boundary={boundary}");
690− let headers = [("x-api-key", key), ("anthropic-version", VERSION), ("content-type", content_type.as_str())];
691− let answer = http::send(Method::Post, &format!("{API}/v1/skills"), &headers, Some(body)).await?;
692− match answer.json()["id"].as_str() {
693− Some(id) if answer.ok() => {
694− ours.insert(name.to_owned(), id.to_owned());
747+ let mut on = held.as_ref().map(|skill| skill.id.clone());
748+ let mut answer = self.upload_skill(key, on.as_deref(), name, content).await?;
749+ // Someone deleted it on the account: make it again.
750+ if on.is_some() && answer.status == 404 {
751+ on = None;
752+ answer = self.upload_skill(key, None, name, content).await?;
753+ }
754+ let uploaded = answer.json();
755+ // A new skill answers with its id; a new version, with the skill's.
756+ let id = match &on {
757+ Some(id) => Some(id.as_str()),
758+ None => uploaded["id"].as_str(),
759+ };
760+ match (answer.ok(), id) {
761+ (true, Some(id)) => {
762+ ours.insert(name.to_owned(), OwnSkill { id: id.to_owned(), version: uploaded_version(&uploaded), sha256: Some(sha256) });
695763 }
764+ // The old version stays in use until an upload works.
696765 _ => worker::console_error!("agent sessions: g1t's {name} skill was not uploaded: {}", answer.problem("Anthropic")),
697766 }
698767 }
699− let mut skills: Vec<Value> = ours.values().map(|id| json!({ "type": "custom", "skill_id": id, "version": "latest" })).collect();
768+ let mut skills: Vec<Value> = ours.values().map(|skill| skill_ref(&skill.id, skill.version.as_deref())).collect();
700769 // The account's own skills, as many as fit.
701770 let headers = [("x-api-key", key), ("anthropic-version", VERSION)];
702771 let listed = http::send(Method::Get, &format!("{API}/v1/skills?source=custom&limit=100"), &headers, None).await?;
706775 if skills.len() >= MAX_SKILLS {
707776 break;
708777 }
709− if !ours.values().any(|own| own == id) {
710− skills.push(json!({ "type": "custom", "skill_id": id, "version": "latest" }));
778+ if !ours.values().any(|own| own.id == id) {
779+ skills.push(skill_ref(id, None));
711780 }
712781 }
713782 }
723792 .bind(&[row.id.as_str().into()])?
724793 .first::<AgentRow>(None)
725794 .await?;
726− let known: BTreeMap<String, String> = stored.as_ref().and_then(|stored| serde_json::from_str(&stored.skills).ok()).unwrap_or_default();
795+ let known = stored.as_ref().map(|stored| own_skills(&stored.skills)).unwrap_or_default();
727796 let (skills, ours) = self.agent_skills(key, &known).await?;
728797 let body = agent_body(model, &self.mcp_url(), &skills);
729798 let hash = g1t_secrets::sha256_hex(&body.to_string());
14451514 }
14461515
14471516 #[test]
1517+ fn g1ts_skills_on_an_account_keep_their_version_and_what_it_holds() {
1518+ // Before versions were kept: the id alone, so it is uploaded again once.
1519+ let old = own_skills(r#"{"work-on-issue":"skill_01A","coordinate":""}"#);
1520+ assert_eq!(old.len(), 1);
1521+ assert_eq!(old["work-on-issue"], OwnSkill { id: "skill_01A".into(), version: None, sha256: None });
1522+ let kept = BTreeMap::from([(
1523+ "work-on-issue".to_owned(),
1524+ OwnSkill { id: "skill_01A".into(), version: Some("1760000000".into()), sha256: Some("abc".into()) },
1525+ )]);
1526+ assert_eq!(own_skills(&serde_json::to_string(&kept).unwrap()), kept);
1527+ assert!(own_skills("not json").is_empty());
1528+ // g1t's own are pinned, so a new version changes the agent; others follow their latest.
1529+ assert_eq!(skill_ref("skill_01A", Some("1760000000")), json!({ "type": "custom", "skill_id": "skill_01A", "version": "1760000000" }));
1530+ assert_eq!(skill_ref("skill_02B", None)["version"], "latest");
1531+ assert_eq!(uploaded_version(&json!({ "version": "1760000001" })).as_deref(), Some("1760000001"));
1532+ assert_eq!(uploaded_version(&json!({ "id": "skill_01A", "latest_version": 1760000000 })).as_deref(), Some("1760000000"));
1533+ assert_eq!(uploaded_version(&json!({ "id": "skill_01A" })), None);
1534+ }
1535+
1536+ #[test]
14481537 fn skills_upload_as_files_in_their_folder() {
14491538 let body = multipart("XYZ", &[("work-on-issue/SKILL.md".to_owned(), "---\nname: work-on-issue\n---\nbody".to_owned())]);
14501539 assert!(body.starts_with("--XYZ\r\nContent-Disposition: form-data; name=\"files[]\"; filename=\"work-on-issue/SKILL.md\"\r\n"));

Comments

Sign in to comment.