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.
2 files+108−180/2 viewed
| 289 | 289 | placeholder that is swapped for the token on its way to g1t. It is never | |
| 290 | 290 | in the prompt, and the model never sees it. The session has g1t's skills | |
| 291 | 291 | (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 | |
| 294 | 295 | as a subagent. | |
| 295 | 296 | - **Without one**, or with `handoff: true`, nothing starts: you get | |
| 296 | 297 | `command`, a Claude Code command line to run in your working copy with |
| 204 | 204 | json!({ "events": [{ "type": "user.interrupt" }] }) | |
| 205 | 205 | } | |
| 206 | 206 | ||
| 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 | + | ||
| 207 | 251 | /// A request body of files, as `multipart/form-data` with `boundary`. | |
| 208 | 252 | pub fn multipart(boundary: &str, files: &[(String, String)]) -> String { | |
| 209 | 253 | let mut body = String::new(); | |
| ⋯ | |||
| 676 | 720 | Ok(Outcome::Ok(self.anthropic_connection(&workspace).await?.0)) | |
| 677 | 721 | } | |
| 678 | 722 | ||
| 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>)> { | |
| 682 | 740 | let mut ours = known.clone(); | |
| 683 | 741 | 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())) { | |
| 685 | 745 | continue; | |
| 686 | 746 | } | |
| 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) }); | |
| 695 | 763 | } | |
| 764 | + | // The old version stays in use until an upload works. | |
| 696 | 765 | _ => worker::console_error!("agent sessions: g1t's {name} skill was not uploaded: {}", answer.problem("Anthropic")), | |
| 697 | 766 | } | |
| 698 | 767 | } | |
| 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(); | |
| 700 | 769 | // The account's own skills, as many as fit. | |
| 701 | 770 | let headers = [("x-api-key", key), ("anthropic-version", VERSION)]; | |
| 702 | 771 | let listed = http::send(Method::Get, &format!("{API}/v1/skills?source=custom&limit=100"), &headers, None).await?; | |
| ⋯ | |||
| 706 | 775 | if skills.len() >= MAX_SKILLS { | |
| 707 | 776 | break; | |
| 708 | 777 | } | |
| 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)); | |
| 711 | 780 | } | |
| 712 | 781 | } | |
| 713 | 782 | } | |
| ⋯ | |||
| 723 | 792 | .bind(&[row.id.as_str().into()])? | |
| 724 | 793 | .first::<AgentRow>(None) | |
| 725 | 794 | .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(); | |
| 727 | 796 | let (skills, ours) = self.agent_skills(key, &known).await?; | |
| 728 | 797 | let body = agent_body(model, &self.mcp_url(), &skills); | |
| 729 | 798 | let hash = g1t_secrets::sha256_hex(&body.to_string()); | |
| ⋯ | |||
| 1445 | 1514 | } | |
| 1446 | 1515 | ||
| 1447 | 1516 | #[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] | |
| 1448 | 1537 | fn skills_upload_as_files_in_their_folder() { | |
| 1449 | 1538 | let body = multipart("XYZ", &[("work-on-issue/SKILL.md".to_owned(), "---\nname: work-on-issue\n---\nbody".to_owned())]); | |
| 1450 | 1539 | 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.