Skip to content

Commit 1a0021b

Browse files
authored
feat(memory): make tags optional with auto-generation fallback (#2)
- Change `tags` field in create_memory tool from required to optional - Update description to clarify tags are auto-generated from content when omitted or blank - Update system prompt to document blank tag (`||`) auto-generation behavior - Add usage example demonstrating automatic semantic tag generation - Refactor `AttachmentMimePolicyService` to extract `resolvePolicy` as a testable static method with explicit parameters
1 parent d9e89b8 commit 1a0021b

20 files changed

Lines changed: 1464 additions & 203 deletions

Axon/Resources/AxonTools/core/memory/create_memory/tool_create_memory.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
},
3434
"tags": {
3535
"type": "string",
36-
"required": true,
37-
"description": "Retrieval context keywords - when should this memory surface? Comma-separated (e.g., 'debugging,swift-help' not just 'swift')"
36+
"required": false,
37+
"description": "Optional retrieval tags. Accepts comma-separated text or tag arrays. If omitted/blank, semantic tags are auto-generated from content."
3838
},
3939
"content": {
4040
"type": "string",
@@ -57,7 +57,7 @@
5757
},
5858

5959
"ai": {
60-
"systemPromptSection": "### create_memory\nSave important information to memory for future conversations. Use this to remember facts about the user, their preferences, important context, or insights.\n\n**Memory Types:**\n- `allocentric`: Facts ABOUT the user (preferences, background, relationships, what they like/dislike)\n- `egoic`: What WORKS for you in this agentic context (approaches, techniques, insights, learnings about how to help them)\n\n**Format:**\n```tool_request\n{\"tool\": \"create_memory\", \"query\": \"TYPE|CONFIDENCE|TAGS|CONTENT\"}\n```\n\n**Parameters (pipe-separated):**\n- TYPE: Either \"allocentric\" or \"egoic\"\n- CONFIDENCE: 0.0-1.0 (how certain you are)\n- TAGS: Retrieval context keywords - when should this memory surface? (e.g., \"debugging,swift-help\" not just \"swift\")\n- CONTENT: The actual fact or insight to remember",
60+
"systemPromptSection": "### create_memory\nSave important information to memory for future conversations. Use this to remember facts about the user, their preferences, important context, or insights.\n\n**Memory Types:**\n- `allocentric`: Facts ABOUT the user (preferences, background, relationships, what they like/dislike)\n- `egoic`: What WORKS for you in this agentic context (approaches, techniques, insights, learnings about how to help them)\n\n**Format:**\n```tool_request\n{\"tool\": \"create_memory\", \"query\": \"TYPE|CONFIDENCE|TAGS|CONTENT\"}\n```\n\n**Parameters (pipe-separated):**\n- TYPE: Either \"allocentric\" or \"egoic\"\n- CONFIDENCE: 0.0-1.0 (how certain you are)\n- TAGS: Optional retrieval context tags (free-form and domain-specific). Comma-separated if provided. Leave blank (`||`) to auto-generate semantic tags from content.\n- CONTENT: The actual fact or insight to remember",
6161
"usageExamples": [
6262
{
6363
"description": "Remember user language preference",
@@ -70,6 +70,10 @@
7070
{
7171
"description": "Remember project context",
7272
"input": "allocentric|0.85|axon,architecture|User is building Axon, an AI assistant app with co-sovereignty features"
73+
},
74+
{
75+
"description": "Allow automatic semantic tag generation",
76+
"input": "allocentric|0.82||User now prefers actionable code-review findings first, then short summary"
7377
}
7478
],
7579
"whenToUse": [

Axon/Services/Conversation/AttachmentMimePolicyService.swift

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -111,26 +111,36 @@ enum AttachmentMimePolicyService {
111111
resolved = ConversationModelResolver.resolveGlobal(settings: settings)
112112
}
113113

114-
let provider = resolved.normalizedProvider
115-
let modelId = resolved.modelId
116-
let providerName = resolved.providerName
114+
return resolvePolicy(
115+
provider: resolved.normalizedProvider,
116+
modelId: resolved.modelId,
117+
providerName: resolved.providerName,
118+
conversationId: conversationId,
119+
settings: settings
120+
)
121+
}
117122

123+
static func resolvePolicy(
124+
provider: String,
125+
modelId: String,
126+
providerName: String,
127+
conversationId: String?,
128+
settings: AppSettings
129+
) -> AttachmentMimePolicy {
130+
let normalizedProvider = normalizedProviderKey(provider)
118131
let patternsByType: [MessageAttachment.AttachmentType: [String]]
119-
switch provider {
132+
switch normalizedProvider {
120133
case "anthropic":
121134
patternsByType = policy(
122135
image: ["image/*"],
123136
document: ["application/pdf", "text/*"]
124137
)
125138

126139
case "openai":
127-
let supportsAudio = modelId.lowercased().contains("4o")
128-
|| modelId.lowercased().contains("audio")
129-
|| modelId.lowercased().contains("realtime")
130140
patternsByType = policy(
131141
image: ["image/*"],
132142
document: [],
133-
audio: supportsAudio ? ["audio/*"] : [],
143+
audio: supportsOpenAIAudio(modelId: modelId) ? ["audio/*"] : [],
134144
video: []
135145
)
136146

@@ -157,14 +167,25 @@ enum AttachmentMimePolicyService {
157167
patternsByType = policy(image: supportsVision ? ["image/*"] : [])
158168

159169
case "openai-compatible":
160-
patternsByType = customProviderPolicy(conversationId: conversationId, settings: settings, fallbackModelCode: modelId)
170+
// Keep strict transport parity with chat-completions payload builders:
171+
// OpenAI-compatible transport currently supports image + audio only.
172+
let rawPolicy = customProviderPolicy(
173+
conversationId: conversationId,
174+
settings: settings,
175+
fallbackModelCode: modelId
176+
)
177+
patternsByType = enforceTransportParity(
178+
rawPolicy,
179+
provider: normalizedProvider,
180+
modelId: modelId
181+
)
161182

162183
default:
163184
patternsByType = policy()
164185
}
165186

166187
return AttachmentMimePolicy(
167-
provider: provider,
188+
provider: normalizedProvider,
168189
modelId: modelId,
169190
providerName: providerName,
170191
allowedPatternsByType: patternsByType
@@ -345,7 +366,11 @@ enum AttachmentMimePolicyService {
345366
settings: AppSettings,
346367
fallbackModelCode: String
347368
) -> [MessageAttachment.AttachmentType: [String]] {
348-
guard let (provider, model) = resolveCustomSelection(conversationId: conversationId, settings: settings) else {
369+
guard let (provider, model) = resolveCustomSelection(
370+
conversationId: conversationId,
371+
settings: settings,
372+
preferredModelCode: fallbackModelCode
373+
) else {
349374
return patternsToTypedPolicy(fallbackCustomPatterns(modelCode: fallbackModelCode))
350375
}
351376

@@ -360,7 +385,8 @@ enum AttachmentMimePolicyService {
360385

361386
private static func resolveCustomSelection(
362387
conversationId: String?,
363-
settings: AppSettings
388+
settings: AppSettings,
389+
preferredModelCode: String?
364390
) -> (provider: CustomProviderConfig, model: CustomModelConfig?)? {
365391
var selectedProviderId = settings.selectedCustomProviderId
366392
var selectedModelId = settings.selectedCustomModelId
@@ -379,7 +405,12 @@ enum AttachmentMimePolicyService {
379405
return nil
380406
}
381407

382-
let model = provider.models.first(where: { $0.id == selectedModelId })
408+
let trimmedPreferredModelCode = preferredModelCode?.trimmingCharacters(in: .whitespacesAndNewlines)
409+
let model = provider.models.first(where: { model in
410+
guard let preferred = trimmedPreferredModelCode, !preferred.isEmpty else { return false }
411+
return model.modelCode.caseInsensitiveCompare(preferred) == .orderedSame
412+
})
413+
?? provider.models.first(where: { $0.id == selectedModelId })
383414
?? provider.models.first
384415

385416
return (provider, model)
@@ -451,6 +482,52 @@ enum AttachmentMimePolicyService {
451482
return mimeAliases[base] ?? base
452483
}
453484

485+
private static func supportsOpenAIAudio(modelId: String) -> Bool {
486+
let normalized = modelId.lowercased()
487+
return normalized.contains("4o")
488+
|| normalized.contains("audio")
489+
|| normalized.contains("realtime")
490+
}
491+
492+
private static func normalizedProviderKey(_ provider: String) -> String {
493+
let normalized = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
494+
return normalized == "xai" ? "grok" : normalized
495+
}
496+
497+
private static func enforceTransportParity(
498+
_ policyByType: [MessageAttachment.AttachmentType: [String]],
499+
provider: String,
500+
modelId: String
501+
) -> [MessageAttachment.AttachmentType: [String]] {
502+
let supportedTypes = transportSupportedAttachmentTypes(provider: provider, modelId: modelId)
503+
var filtered = policy()
504+
505+
for type in allAttachmentTypes {
506+
filtered[type] = supportedTypes.contains(type) ? (policyByType[type] ?? []) : []
507+
}
508+
509+
return filtered
510+
}
511+
512+
private static func transportSupportedAttachmentTypes(
513+
provider: String,
514+
modelId: String
515+
) -> Set<MessageAttachment.AttachmentType> {
516+
switch provider {
517+
case "openai":
518+
var supported: Set<MessageAttachment.AttachmentType> = [.image]
519+
if supportsOpenAIAudio(modelId: modelId) {
520+
supported.insert(.audio)
521+
}
522+
return supported
523+
case "openai-compatible":
524+
// Current OpenAI-compatible payload builders only serialize image + audio.
525+
return [.image, .audio]
526+
default:
527+
return Set(allAttachmentTypes)
528+
}
529+
}
530+
454531
private static func isValidMimeToken(_ token: String) -> Bool {
455532
guard !token.isEmpty else { return false }
456533
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789!#$&^_.+-")

Axon/Services/Conversation/ConversationService.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,13 @@ class ConversationService: ObservableObject {
630630
let resolvedModelParams = runtimeOverrides.modelParams
631631

632632
if !attachments.isEmpty {
633-
let policy = AttachmentMimePolicyService.resolvePolicy(conversationId: conversationId, settings: settings)
633+
let policy = AttachmentMimePolicyService.resolvePolicy(
634+
provider: providerString,
635+
modelId: modelId,
636+
providerName: providerDisplayName,
637+
conversationId: conversationId,
638+
settings: settings
639+
)
634640
let validation = AttachmentMimePolicyService.validate(attachments: attachments, policy: policy)
635641
if case .rejected(let failures) = validation {
636642
throw APIError.networkError(

Axon/Services/Conversation/OnDeviceConversationOrchestrator.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2599,12 +2599,15 @@ class OnDeviceConversationOrchestrator: ConversationOrchestrator {
25992599
"format": format
26002600
]
26012601
])
2602+
} else if attachment.url != nil {
2603+
print("[OnDeviceOrchestrator] OpenAI/OpenAI-compatible payload dropped audio URL attachment '\(attachment.name ?? attachment.id)' (\(mimeType)); input_audio requires inline/base64 data.")
26022604
}
26032605
// Note: OpenAI doesn't support audio URLs directly
26042606

26052607
case .document, .video:
26062608
// OpenAI doesn't natively support PDFs or video in chat completions
26072609
// Skip these for now - would need separate handling
2610+
print("[OnDeviceOrchestrator] OpenAI/OpenAI-compatible payload dropped unsupported \(attachment.type.rawValue) attachment '\(attachment.name ?? attachment.id)' (\(mimeType)).")
26082611
continue
26092612
}
26102613
}

Axon/Services/Security/DeviceIdentity.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,12 @@ class DeviceIdentity {
9393

9494
/// Generate a signature for this device (useful for API auth)
9595
func generateDeviceSignature(data: String) -> String {
96-
let key = SymmetricKey(data: Data(getDeviceId().utf8))
96+
generateDeviceSignature(data: data, usingDeviceId: getDeviceId())
97+
}
98+
99+
/// Generate a deterministic signature using an explicit device ID context.
100+
func generateDeviceSignature(data: String, usingDeviceId deviceId: String) -> String {
101+
let key = SymmetricKey(data: Data(deviceId.utf8))
97102
let signature = HMAC<SHA256>.authenticationCode(for: Data(data.utf8), using: key)
98103
return Data(signature).base64EncodedString()
99104
}

0 commit comments

Comments
 (0)