Skip to content

Commit f7ddff5

Browse files
committed
feat(api/sdk): editing trigger + auto-trim strategies
- Gate edit_strategies with editing_trigger\n- Add auto-trim strategies + trigger checks\n- Update Python/TypeScript SDK sessions + docs
1 parent 55a287c commit f7ddff5

6 files changed

Lines changed: 197 additions & 30 deletions

File tree

docs/engineering/editing.mdx

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,32 +9,25 @@ Apply edit strategies when retrieving messages to manage context window size. Th
99

1010
The `get_messages` response includes `this_time_tokens` - the total token count of returned messages. Use this to:
1111
- Check current context window size
12-
- Decide when to apply edit strategies
12+
- Apply edit strategies only when needed
1313
- Determine when to [reset the prompt cache](/engineering/cache)
1414

1515
<CodeGroup>
1616
```python Python
17-
result = client.sessions.get_messages(session_id="session-uuid")
17+
result = client.sessions.get_messages(
18+
session_id="session-uuid",
19+
edit_strategies=[{"type": "token_limit", "params": {"limit_tokens": 30000}}],
20+
editing_trigger={"token_gte": 50000},
21+
)
1822
print(f"Current tokens: {result.this_time_tokens}")
19-
20-
if result.this_time_tokens > 50000:
21-
# Apply strategies to reduce context
22-
result = client.sessions.get_messages(
23-
session_id="session-uuid",
24-
edit_strategies=[{"type": "token_limit", "params": {"limit_tokens": 30000}}]
25-
)
2623
```
2724

2825
```typescript TypeScript
29-
let result = await client.sessions.getMessages("session-uuid");
26+
const result = await client.sessions.getMessages("session-uuid", {
27+
editStrategies: [{ type: "token_limit", params: { limit_tokens: 30000 } }],
28+
editingTrigger: { token_gte: 50000 },
29+
});
3030
console.log(`Current tokens: ${result.thisTimeTokens}`);
31-
32-
if (result.thisTimeTokens > 50000) {
33-
// Apply strategies to reduce context
34-
result = await client.sessions.getMessages("session-uuid", {
35-
editStrategies: [{ type: "token_limit", params: { limit_tokens: 30000 } }],
36-
});
37-
}
3831
```
3932
</CodeGroup>
4033

src/client/acontext-py/src/acontext/resources/async_sessions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,8 @@ async def get_messages(
324324
format: Literal["acontext", "openai", "anthropic", "gemini"] = "openai",
325325
time_desc: bool | None = None,
326326
edit_strategies: Optional[List[EditStrategy]] = None,
327+
# editing_trigger triggers edit_strategies (v0 supports {"token_gte": int}).
328+
editing_trigger: dict[str, Any] | None = None,
327329
pin_editing_strategies_at_message: str | None = None,
328330
) -> GetMessagesOutput:
329331
"""Get messages for a session.
@@ -344,6 +346,7 @@ async def get_messages(
344346
- Middle out: [{"type": "middle_out", "params": {"token_reduce_to": 5000}}]
345347
- Token limit: [{"type": "token_limit", "params": {"limit_tokens": 20000}}]
346348
Defaults to None.
349+
editing_trigger: Trigger config for edit_strategies, e.g. {"token_gte": 30000}. Defaults to None.
347350
pin_editing_strategies_at_message: Message ID to pin editing strategies at.
348351
When provided, strategies are only applied to messages up to and including
349352
this message ID, keeping subsequent messages unchanged. This helps maintain
@@ -368,6 +371,10 @@ async def get_messages(
368371
if edit_strategies is not None:
369372
validate_edit_strategies(edit_strategies)
370373
params["edit_strategies"] = json.dumps(edit_strategies)
374+
if editing_trigger is not None:
375+
if isinstance(editing_trigger, BaseModel):
376+
editing_trigger = editing_trigger.model_dump()
377+
params["editing_trigger"] = json.dumps(editing_trigger)
371378
if pin_editing_strategies_at_message is not None:
372379
params["pin_editing_strategies_at_message"] = (
373380
pin_editing_strategies_at_message

src/client/acontext-py/src/acontext/resources/sessions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,8 @@ def get_messages(
324324
format: Literal["acontext", "openai", "anthropic", "gemini"] = "openai",
325325
time_desc: bool | None = None,
326326
edit_strategies: Optional[List[EditStrategy]] = None,
327+
# editing_trigger triggers edit_strategies (v0 supports {"token_gte": int}).
328+
editing_trigger: dict[str, Any] | None = None,
327329
pin_editing_strategies_at_message: str | None = None,
328330
) -> GetMessagesOutput:
329331
"""Get messages for a session.
@@ -344,6 +346,7 @@ def get_messages(
344346
- Middle out: [{"type": "middle_out", "params": {"token_reduce_to": 5000}}]
345347
- Token limit: [{"type": "token_limit", "params": {"limit_tokens": 20000}}]
346348
Defaults to None.
349+
editing_trigger: Trigger config for edit_strategies, e.g. {"token_gte": 30000}. Defaults to None.
347350
pin_editing_strategies_at_message: Message ID to pin editing strategies at.
348351
When provided, strategies are only applied to messages up to and including
349352
this message ID, keeping subsequent messages unchanged. This helps maintain
@@ -368,6 +371,10 @@ def get_messages(
368371
if edit_strategies is not None:
369372
validate_edit_strategies(edit_strategies)
370373
params["edit_strategies"] = json.dumps(edit_strategies)
374+
if editing_trigger is not None:
375+
if isinstance(editing_trigger, BaseModel):
376+
editing_trigger = editing_trigger.model_dump()
377+
params["editing_trigger"] = json.dumps(editing_trigger)
371378
if pin_editing_strategies_at_message is not None:
372379
params["pin_editing_strategies_at_message"] = (
373380
pin_editing_strategies_at_message

src/client/acontext-ts/src/resources/sessions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ export class SessionsAPI {
284284
* @param options.format - The format of the messages ('acontext', 'openai', 'anthropic', or 'gemini').
285285
* @param options.timeDesc - Order by created_at descending if true, ascending if false.
286286
* @param options.editStrategies - Optional list of edit strategies to apply before format conversion.
287+
* @param options.editingTrigger - Optional trigger config for editStrategies (v0 supports { token_gte: number }).
287288
* Examples:
288289
* - Remove tool results: [{ type: 'remove_tool_result', params: { keep_recent_n_tool_results: 3 } }]
289290
* - Remove large tool results: [{ type: 'remove_tool_result', params: { gt_token: 100 } }]
@@ -307,6 +308,7 @@ export class SessionsAPI {
307308
format?: 'acontext' | 'openai' | 'anthropic' | 'gemini';
308309
timeDesc?: boolean | null;
309310
editStrategies?: Array<EditStrategy> | null;
311+
editingTrigger?: Record<string, unknown> | null;
310312
pinEditingStrategiesAtMessage?: string | null;
311313
}
312314
): Promise<GetMessagesOutput> {
@@ -327,6 +329,9 @@ export class SessionsAPI {
327329
EditStrategySchema.array().parse(options.editStrategies);
328330
params.edit_strategies = JSON.stringify(options.editStrategies);
329331
}
332+
if (options?.editingTrigger !== undefined && options?.editingTrigger !== null) {
333+
params.editing_trigger = JSON.stringify(options.editingTrigger);
334+
}
330335
if (options?.pinEditingStrategiesAtMessage !== undefined && options?.pinEditingStrategiesAtMessage !== null) {
331336
params.pin_editing_strategies_at_message = options.pinEditingStrategiesAtMessage;
332337
}

src/server/api/go/internal/modules/handler/session.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ type GetMessagesReq struct {
443443
Format string `form:"format,default=openai" json:"format" binding:"omitempty,oneof=acontext openai anthropic gemini" example:"openai" enums:"acontext,openai,anthropic,gemini"`
444444
TimeDesc bool `form:"time_desc,default=false" json:"time_desc" example:"false"`
445445
EditStrategies string `form:"edit_strategies" json:"edit_strategies" example:"[{\"type\":\"remove_tool_result\",\"params\":{\"keep_recent_n_tool_results\":3}}]"`
446+
EditingTrigger string `form:"editing_trigger" json:"editing_trigger" example:"{\"token_gte\":30000}"`
446447
PinEditingStrategiesAtMessage string `form:"pin_editing_strategies_at_message" json:"pin_editing_strategies_at_message" example:""`
447448
}
448449

@@ -460,6 +461,7 @@ type GetMessagesReq struct {
460461
// @Param format query string false "Format to convert messages to: acontext (original), openai (default), anthropic, gemini." enums(acontext,openai,anthropic,gemini)
461462
// @Param time_desc query boolean false "Order by created_at descending if true, ascending if false (default false)" example(false)
462463
// @Param edit_strategies query string false "JSON array of edit strategies to apply before format conversion" example([{"type":"remove_tool_result","params":{"keep_recent_n_tool_results":3}}])
464+
// @Param editing_trigger query string false "JSON object trigger for edit_strategies. v0 supports only {\"token_gte\": <int>} (OR semantics when more triggers are added)." example({"token_gte":30000})
463465
// @Param pin_editing_strategies_at_message query string false "Message ID to pin editing strategies at. When provided, strategies are only applied to messages up to and including this message ID, keeping subsequent messages unchanged. This helps maintain prompt cache stability by preserving a stable prefix. The response will include edit_at_message_id indicating where strategies were applied." example()
464466
// @Security BearerAuth
465467
// @Success 200 {object} serializer.Response{data=converter.GetMessagesOutput}
@@ -493,6 +495,58 @@ func (h *SessionHandler) GetMessages(c *gin.Context) {
493495
}
494496
}
495497

498+
// Parse editing_trigger if provided (v0 supports only token_gte).
499+
var editingTrigger *service.EditingTrigger
500+
if req.EditingTrigger != "" {
501+
if req.EditStrategies == "" {
502+
c.JSON(http.StatusBadRequest, serializer.ParamErr("editing_trigger requires edit_strategies", errors.New("missing edit_strategies")))
503+
return
504+
}
505+
506+
var raw map[string]interface{}
507+
if err := sonic.Unmarshal([]byte(req.EditingTrigger), &raw); err != nil {
508+
c.JSON(http.StatusBadRequest, serializer.ParamErr("invalid editing_trigger JSON", err))
509+
return
510+
}
511+
allowedTriggerKeys := map[string]struct{}{
512+
"token_gte": {},
513+
}
514+
for k := range raw {
515+
if _, ok := allowedTriggerKeys[k]; !ok {
516+
c.JSON(http.StatusBadRequest, serializer.ParamErr("invalid editing_trigger", fmt.Errorf("unsupported trigger: %s", k)))
517+
return
518+
}
519+
}
520+
521+
var trig service.EditingTrigger
522+
if err := sonic.Unmarshal([]byte(req.EditingTrigger), &trig); err != nil {
523+
c.JSON(http.StatusBadRequest, serializer.ParamErr("invalid editing_trigger JSON", err))
524+
return
525+
}
526+
527+
triggerValidators := map[string]func(service.EditingTrigger) error{
528+
"token_gte": func(t service.EditingTrigger) error {
529+
if t.TokenGte == nil || *t.TokenGte <= 0 {
530+
return errors.New("token_gte must be > 0")
531+
}
532+
return nil
533+
},
534+
}
535+
for k := range raw {
536+
validate, ok := triggerValidators[k]
537+
if !ok {
538+
// Should be unreachable due to allowedTriggerKeys check above.
539+
c.JSON(http.StatusBadRequest, serializer.ParamErr("invalid editing_trigger", fmt.Errorf("unsupported trigger: %s", k)))
540+
return
541+
}
542+
if err := validate(trig); err != nil {
543+
c.JSON(http.StatusBadRequest, serializer.ParamErr("invalid editing_trigger."+k, err))
544+
return
545+
}
546+
}
547+
editingTrigger = &trig
548+
}
549+
496550
out, err := h.svc.GetMessages(c.Request.Context(), service.GetMessagesInput{
497551
SessionID: sessionID,
498552
Limit: limit,
@@ -501,6 +555,7 @@ func (h *SessionHandler) GetMessages(c *gin.Context) {
501555
AssetExpire: time.Hour * 24,
502556
TimeDesc: req.TimeDesc,
503557
EditStrategies: editStrategies,
558+
EditingTrigger: editingTrigger,
504559
PinEditingStrategiesAtMessage: req.PinEditingStrategiesAtMessage,
505560
})
506561
if err != nil {

src/server/api/go/internal/modules/service/session.go

Lines changed: 113 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/memodb-io/Acontext/internal/modules/repo"
1919
"github.com/memodb-io/Acontext/internal/pkg/editor"
2020
"github.com/memodb-io/Acontext/internal/pkg/paging"
21+
"github.com/memodb-io/Acontext/internal/pkg/tokenizer"
2122
"github.com/redis/go-redis/v9"
2223
"go.uber.org/zap"
2324
"gorm.io/datatypes"
@@ -379,14 +380,66 @@ func (s *sessionService) StoreMessage(ctx context.Context, in StoreMessageInput)
379380
}
380381

381382
type GetMessagesInput struct {
382-
SessionID uuid.UUID `json:"session_id"`
383-
Limit int `json:"limit"`
384-
Cursor string `json:"cursor"`
385-
WithAssetPublicURL bool `json:"with_public_url"`
386-
AssetExpire time.Duration `json:"asset_expire"`
387-
TimeDesc bool `json:"time_desc"`
388-
EditStrategies []editor.StrategyConfig `json:"edit_strategies,omitempty"`
389-
PinEditingStrategiesAtMessage string `json:"pin_editing_strategies_at_message,omitempty"`
383+
SessionID uuid.UUID `json:"session_id"`
384+
Limit int `json:"limit"`
385+
Cursor string `json:"cursor"`
386+
WithAssetPublicURL bool `json:"with_public_url"`
387+
AssetExpire time.Duration `json:"asset_expire"`
388+
TimeDesc bool `json:"time_desc"`
389+
EditStrategies []editor.StrategyConfig `json:"edit_strategies,omitempty"`
390+
// EditingTrigger holds optional trigger config for applying edit_strategies.
391+
EditingTrigger *EditingTrigger `json:"editing_trigger,omitempty"`
392+
PinEditingStrategiesAtMessage string `json:"pin_editing_strategies_at_message,omitempty"`
393+
}
394+
395+
// EditingTrigger defines trigger configuration for applying edit_strategies.
396+
// v0 supports only token_gte.
397+
type EditingTrigger struct {
398+
// TokenGte triggers edit strategies when the token count is greater than or equal to this value.
399+
TokenGte *int `json:"token_gte,omitempty"`
400+
}
401+
402+
type editingTriggerEval struct {
403+
sessionID uuid.UUID
404+
messages []model.Message
405+
406+
tokenCount *int
407+
}
408+
409+
func (e *editingTriggerEval) Tokens(ctx context.Context) (int, error) {
410+
if e.tokenCount != nil {
411+
return *e.tokenCount, nil
412+
}
413+
414+
tokens, err := tokenizer.CountMessagePartsTokens(ctx, e.messages)
415+
if err != nil {
416+
return 0, fmt.Errorf("failed to count tokens for editing_trigger session_id=%s: %w", e.sessionID, err)
417+
}
418+
419+
e.tokenCount = &tokens
420+
return tokens, nil
421+
}
422+
423+
type editingTriggerCheck func(ctx context.Context, eval *editingTriggerEval) (bool, error)
424+
425+
func buildEditingTriggerChecks(trigger *EditingTrigger) []editingTriggerCheck {
426+
checks := make([]editingTriggerCheck, 0, 1)
427+
if trigger == nil {
428+
return checks
429+
}
430+
431+
if trigger.TokenGte != nil && *trigger.TokenGte > 0 {
432+
threshold := *trigger.TokenGte
433+
checks = append(checks, func(ctx context.Context, eval *editingTriggerEval) (bool, error) {
434+
tokens, err := eval.Tokens(ctx)
435+
if err != nil {
436+
return false, err
437+
}
438+
return tokens >= threshold, nil
439+
})
440+
}
441+
442+
return checks
390443
}
391444

392445
type PublicURL struct {
@@ -464,12 +517,59 @@ func (s *sessionService) GetMessages(ctx context.Context, in GetMessagesInput) (
464517

465518
// Apply edit strategies if provided (before format conversion)
466519
if len(in.EditStrategies) > 0 {
467-
result, err := editor.ApplyStrategiesWithPin(out.Items, in.EditStrategies, in.PinEditingStrategiesAtMessage)
468-
if err != nil {
469-
return nil, fmt.Errorf("failed to apply edit strategies: %w", err)
520+
applyEditStrategies := true
521+
triggerChecks := buildEditingTriggerChecks(in.EditingTrigger)
522+
if len(triggerChecks) > 0 {
523+
// Evaluate trigger on the same editable prefix used by pin_editing_strategies_at_message.
524+
triggerMessages := out.Items
525+
effectivePin := ""
526+
if in.PinEditingStrategiesAtMessage != "" {
527+
pinIndex := -1
528+
for i := range out.Items {
529+
if out.Items[i].ID.String() == in.PinEditingStrategiesAtMessage {
530+
pinIndex = i
531+
break
532+
}
533+
}
534+
if pinIndex != -1 {
535+
effectivePin = in.PinEditingStrategiesAtMessage
536+
triggerMessages = out.Items[:pinIndex+1]
537+
}
538+
}
539+
540+
// OR semantics: apply when any trigger check passes.
541+
applyEditStrategies = false
542+
eval := &editingTriggerEval{sessionID: in.SessionID, messages: triggerMessages}
543+
for _, check := range triggerChecks {
544+
ok, err := check(ctx, eval)
545+
if err != nil {
546+
return nil, err
547+
}
548+
if ok {
549+
applyEditStrategies = true
550+
break
551+
}
552+
}
553+
554+
if !applyEditStrategies && len(out.Items) > 0 {
555+
if effectivePin != "" {
556+
out.EditAtMessageID = effectivePin
557+
} else {
558+
out.EditAtMessageID = out.Items[len(out.Items)-1].ID.String()
559+
}
560+
}
561+
}
562+
563+
if applyEditStrategies {
564+
result, err := editor.ApplyStrategiesWithPin(out.Items, in.EditStrategies, in.PinEditingStrategiesAtMessage)
565+
if err != nil {
566+
return nil, fmt.Errorf("failed to apply edit strategies: %w", err)
567+
}
568+
out.Items = result.Messages
569+
out.EditAtMessageID = result.EditAtMessageID
570+
} else if out.EditAtMessageID == "" && len(out.Items) > 0 {
571+
out.EditAtMessageID = out.Items[len(out.Items)-1].ID.String()
470572
}
471-
out.Items = result.Messages
472-
out.EditAtMessageID = result.EditAtMessageID
473573
} else if len(out.Items) > 0 {
474574
// No strategies, but still set EditAtMessageID to the last message
475575
out.EditAtMessageID = out.Items[len(out.Items)-1].ID.String()

0 commit comments

Comments
 (0)