Skip to content

Commit a9a0180

Browse files
batonogovHermes
andauthored
feat(agent): add vendor-neutral adapter core (#1303) (#1314)
* feat(agent): add vendor-neutral adapter core (#1303) * fix(agent): use actor-native isolation (#1303) * fix(agent): bind adapter activation authority (#1303) * fix(agent): close adapter authority review findings (#1303) * fix(agent): serialize adapter lifecycle authority (#1303) * fix(agent): supervise adapter cleanup and cancellation (#1303) * fix(agent): reserve adapter cleanup capacity (#1303) * fix(agent): clean up abandoned adapter sessions (#1303) * fix(agent): harden adapter cleanup handoff (#1303) * fix: stabilize adapter lifecycle verification --------- Co-authored-by: Hermes <bot@hermes.local>
1 parent dc272b0 commit a9a0180

8 files changed

Lines changed: 5497 additions & 0 deletions
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import Foundation
2+
3+
nonisolated struct PineAdapterContractVersion: Hashable, Comparable, Sendable {
4+
let major: UInt16
5+
let minor: UInt16
6+
static func < (lhs: Self, rhs: Self) -> Bool {
7+
(lhs.major, lhs.minor) < (rhs.major, rhs.minor)
8+
}
9+
}
10+
11+
nonisolated struct PineAdapterContractVersionRange: Equatable, Sendable {
12+
let minimum: PineAdapterContractVersion
13+
let maximum: PineAdapterContractVersion
14+
var isValid: Bool { minimum <= maximum }
15+
func contains(_ value: PineAdapterContractVersion) -> Bool { minimum <= value && value <= maximum }
16+
}
17+
18+
nonisolated enum AdapterTransport: String, Hashable, Sendable { case ownedStandardIO, authenticatedLocalIPC }
19+
nonisolated enum AdapterLifecycleScope: String, Hashable, Sendable { case session, run, turn, item }
20+
nonisolated enum AdapterLifecyclePhase: String, Hashable, Sendable {
21+
case started, working, waitingForQuestion, waitingForApproval, succeeded, failed, cancelled, settled
22+
}
23+
nonisolated enum AdapterEvidence: String, Hashable, Sendable { case tool, fileChange }
24+
nonisolated enum AdapterReplay: String, Hashable, Sendable { case none, sourceCursor }
25+
nonisolated enum AdapterOrdering: String, Hashable, Sendable { case unordered, ordered }
26+
nonisolated enum CoreAuthenticationRequirement: String, Hashable, Sendable {
27+
case ownedChildPipe, authenticatedPeer
28+
}
29+
30+
nonisolated enum AdapterProfileError: Error, Equatable, Sendable {
31+
case emptyLifecycle
32+
case missingDependency
33+
case replayRequiresOrdering
34+
case transportAuthenticationMismatch
35+
}
36+
37+
nonisolated struct AdapterLifecycleCapabilities: Hashable, Sendable {
38+
struct Signal: Hashable, Sendable {
39+
let scope: AdapterLifecycleScope
40+
let phase: AdapterLifecyclePhase
41+
}
42+
43+
let signals: Set<Signal>
44+
let evidence: Set<AdapterEvidence>
45+
46+
init(signals: Set<Signal>, evidence: Set<AdapterEvidence>) throws {
47+
guard !signals.isEmpty else { throw AdapterProfileError.emptyLifecycle }
48+
let scopes = Set(signals.map(\.scope))
49+
guard !scopes.contains(.item) || scopes.contains(.turn) else { throw AdapterProfileError.missingDependency }
50+
guard !evidence.contains(.fileChange) || evidence.contains(.tool) else {
51+
throw AdapterProfileError.missingDependency
52+
}
53+
self.signals = signals
54+
self.evidence = evidence
55+
}
56+
57+
func authorizes(scope: AdapterLifecycleScope, phase: AdapterLifecyclePhase) -> Bool {
58+
signals.contains(Signal(scope: scope, phase: phase))
59+
}
60+
}
61+
62+
nonisolated struct AdapterDeliverySemantics: Hashable, Sendable {
63+
let replay: AdapterReplay
64+
let ordering: AdapterOrdering
65+
let minimumAuthentication: CoreAuthenticationRequirement
66+
67+
init(
68+
replay: AdapterReplay = .none,
69+
ordering: AdapterOrdering,
70+
minimumAuthentication: CoreAuthenticationRequirement
71+
) throws {
72+
guard replay == .none || ordering == .ordered else { throw AdapterProfileError.replayRequiresOrdering }
73+
self.replay = replay
74+
self.ordering = ordering
75+
self.minimumAuthentication = minimumAuthentication
76+
}
77+
}
78+
79+
nonisolated struct AdapterCapabilityProfile: Hashable, Sendable {
80+
let transport: AdapterTransport
81+
let lifecycle: AdapterLifecycleCapabilities
82+
let delivery: AdapterDeliverySemantics
83+
84+
var lifecycleSignals: Set<AdapterLifecycleCapabilities.Signal> { lifecycle.signals }
85+
var evidence: Set<AdapterEvidence> { lifecycle.evidence }
86+
var replay: AdapterReplay { delivery.replay }
87+
var ordering: AdapterOrdering { delivery.ordering }
88+
var minimumAuthentication: CoreAuthenticationRequirement { delivery.minimumAuthentication }
89+
90+
init(
91+
transport: AdapterTransport,
92+
lifecycle: AdapterLifecycleCapabilities,
93+
delivery: AdapterDeliverySemantics
94+
) throws {
95+
guard (transport == .ownedStandardIO && delivery.minimumAuthentication == .ownedChildPipe)
96+
|| (transport == .authenticatedLocalIPC && delivery.minimumAuthentication == .authenticatedPeer) else {
97+
throw AdapterProfileError.transportAuthenticationMismatch
98+
}
99+
self.transport = transport
100+
self.lifecycle = lifecycle
101+
self.delivery = delivery
102+
}
103+
104+
var deterministicWireValues: [String] {
105+
(["transport:\(transport.rawValue)", "replay:\(replay.rawValue)",
106+
"ordering:\(ordering.rawValue)", "authentication:\(minimumAuthentication.rawValue)"]
107+
+ lifecycleSignals.map { "lifecycle:\($0.scope.rawValue):\($0.phase.rawValue)" }
108+
+ evidence.map { "evidence:\($0.rawValue)" }).sorted()
109+
}
110+
111+
func isSubset(of maximum: Self) -> Bool {
112+
transport == maximum.transport && lifecycleSignals.isSubset(of: maximum.lifecycleSignals)
113+
&& evidence.isSubset(of: maximum.evidence)
114+
&& replay == maximum.replay && ordering == maximum.ordering
115+
&& minimumAuthentication == maximum.minimumAuthentication
116+
}
117+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import Foundation
2+
3+
nonisolated enum AdapterValueError: Error, Equatable, Sendable {
4+
case empty(String)
5+
case tooLong(String, maximum: Int)
6+
case invalidCharacters(String)
7+
case invalidDisplayText
8+
}
9+
10+
nonisolated protocol CanonicalAdapterIdentifier: Hashable, Sendable {
11+
var value: String { get }
12+
init(validating value: String) throws
13+
}
14+
15+
nonisolated enum AdapterIdentifierValidation {
16+
static let maximumBytes = 96
17+
18+
static func canonical(_ value: String, field: String) throws -> String {
19+
guard !value.isEmpty else { throw AdapterValueError.empty(field) }
20+
guard value.utf8.count <= maximumBytes else {
21+
throw AdapterValueError.tooLong(field, maximum: maximumBytes)
22+
}
23+
let bytes = Array(value.utf8)
24+
guard bytes.allSatisfy({
25+
(97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 46 || $0 == 58
26+
}), bytes.first.map({ (97...122).contains($0) || (48...57).contains($0) }) == true,
27+
bytes.last.map({ (97...122).contains($0) || (48...57).contains($0) }) == true else {
28+
throw AdapterValueError.invalidCharacters(field)
29+
}
30+
return value
31+
}
32+
33+
static func displayText(_ value: String) throws -> String {
34+
let normalized = value.precomposedStringWithCanonicalMapping
35+
guard !normalized.isEmpty, normalized.utf8.count <= 128,
36+
normalized.unicodeScalars.allSatisfy({ scalar in
37+
!CharacterSet.controlCharacters.contains(scalar)
38+
&& ![0x061C, 0x200E, 0x200F, 0x202A, 0x202B, 0x202C, 0x202D, 0x202E,
39+
0x2066, 0x2067, 0x2068, 0x2069].contains(scalar.value)
40+
&& !scalar.properties.isDefaultIgnorableCodePoint
41+
}) else { throw AdapterValueError.invalidDisplayText }
42+
return normalized
43+
}
44+
}
45+
46+
nonisolated struct AgentID: CanonicalAdapterIdentifier {
47+
let value: String
48+
init(validating value: String) throws {
49+
self.value = try AdapterIdentifierValidation.canonical(value, field: "agentID")
50+
}
51+
52+
init(migratingLegacyStableIdentifier value: String) throws {
53+
guard ["claudeCode", "codex", "aider", "copilot", "pi"].contains(value) else {
54+
throw AdapterValueError.invalidCharacters("legacyAgentID")
55+
}
56+
self.value = value
57+
}
58+
}
59+
60+
nonisolated struct AdapterID: CanonicalAdapterIdentifier {
61+
let value: String
62+
init(validating value: String) throws {
63+
self.value = try AdapterIdentifierValidation.canonical(value, field: "adapterID")
64+
}
65+
}
66+
67+
nonisolated struct AdapterFactoryID: CanonicalAdapterIdentifier {
68+
let value: String
69+
init(validating value: String) throws {
70+
self.value = try AdapterIdentifierValidation.canonical(value, field: "factoryID")
71+
}
72+
}
73+
74+
nonisolated struct ExecutableAlias: CanonicalAdapterIdentifier {
75+
let value: String
76+
init(validating value: String) throws {
77+
self.value = try AdapterIdentifierValidation.canonical(value, field: "executableAlias")
78+
guard !value.contains(":"), !value.contains(".") else {
79+
throw AdapterValueError.invalidCharacters("executableAlias")
80+
}
81+
}
82+
}
83+
84+
nonisolated struct VendorReference: Hashable, Sendable, CustomStringConvertible,
85+
CustomDebugStringConvertible, CustomReflectable {
86+
enum Role: String, Sendable { case conversation, turn, item, toolCall, request, event }
87+
let role: Role
88+
private(set) var rawValue: String
89+
90+
init(role: Role, value: String) throws {
91+
guard !value.isEmpty else { throw AdapterValueError.empty("vendorReference") }
92+
guard value.utf8.count <= 256 else {
93+
throw AdapterValueError.tooLong("vendorReference", maximum: 256)
94+
}
95+
guard value.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) else {
96+
throw AdapterValueError.invalidCharacters("vendorReference")
97+
}
98+
self.role = role
99+
rawValue = value
100+
}
101+
102+
var description: String { "<redacted:\(role.rawValue)>" }
103+
var debugDescription: String { description }
104+
var customMirror: Mirror { Mirror(self, children: ["value": description]) }
105+
}
106+
107+
nonisolated struct AdapterResumePosition: Sendable, CustomStringConvertible,
108+
CustomDebugStringConvertible, CustomReflectable {
109+
private(set) var rawValue: String
110+
init(_ value: String) throws {
111+
guard !value.isEmpty else { throw AdapterValueError.empty("resumePosition") }
112+
guard value.utf8.count <= 256 else {
113+
throw AdapterValueError.tooLong("resumePosition", maximum: 256)
114+
}
115+
guard value.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) else {
116+
throw AdapterValueError.invalidCharacters("resumePosition")
117+
}
118+
rawValue = value
119+
}
120+
var description: String { "<redacted:resume-position>" }
121+
var debugDescription: String { description }
122+
var customMirror: Mirror { Mirror(self, children: ["value": description]) }
123+
}

0 commit comments

Comments
 (0)