output-ledger.ts 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
  1. /** Combined byte accounting for retained program output. */
  2. import type { PtcJsonValue, PtcRunFailure, PtcRunResult } from '@deepseek-ai/dsh-ptc-runtime'
  3. import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
  4. /** One run's combined outer-output ledger; binding values never enter it. */
  5. export class OutputLedger {
  6. private bytes = 2 // JSON serialization of the empty logs array: []
  7. private entries = 0
  8. constructor(private readonly maxBytes: number) {}
  9. /** Admit one exact log entry, or report that the hard cap was crossed. */
  10. admit(text: string, sink: string[]): boolean {
  11. const separatorBytes = this.entries > 0 ? 1 : 0
  12. const stringBytes = jsonStringBytesUpTo(text, this.maxBytes - this.bytes - separatorBytes)
  13. if (stringBytes === undefined) return false
  14. this.bytes += stringBytes + separatorBytes
  15. this.entries += 1
  16. sink.push(text)
  17. return true
  18. }
  19. /** Finalize a successful absent-or-JSON completion against the combined cap. */
  20. success(logs: string[], value?: PtcJsonValue): PtcRunResult {
  21. if (value !== undefined && jsonValueBytesUpTo(value, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
  22. return { logs, ...value !== undefined ? { value } : {} }
  23. }
  24. /** Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */
  25. failure(logs: string[], error: PtcRunFailure): PtcRunResult {
  26. if (jsonStringBytesUpTo(error.message, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
  27. return { logs, error }
  28. }
  29. /** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
  30. limit(logs: string[]): PtcRunResult {
  31. const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
  32. // The fixed diagnostic is ASCII, so every character is one byte plus the quotes.
  33. const messageBytes = fullMessage.length + 2
  34. const retained: string[] = []
  35. let retainedBytes = 2
  36. const logBudget = this.maxBytes - messageBytes
  37. for (const text of logs) {
  38. const separatorBytes = retained.length > 0 ? 1 : 0
  39. const availableBytes = logBudget - retainedBytes - separatorBytes
  40. const stringBytes = jsonStringBytesUpTo(text, availableBytes)
  41. if (stringBytes !== undefined) {
  42. retained.push(text)
  43. retainedBytes += stringBytes + separatorBytes
  44. continue
  45. }
  46. const prefix = truncateJsonStringBytes(text, availableBytes)
  47. if (prefix.length > 0) {
  48. const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes)
  49. /* v8 ignore next -- truncateJsonStringBytes guarantees its returned prefix fits the same budget. */
  50. if (prefixBytes === undefined) throw new Error('output ledger produced an oversized log prefix')
  51. retained.push(prefix)
  52. retainedBytes += prefixBytes + separatorBytes
  53. }
  54. break
  55. }
  56. const availableMessageBytes = this.maxBytes - retainedBytes
  57. const message = truncateJsonStringBytes(fullMessage, availableMessageBytes)
  58. return { logs: retained, error: { kind: 'output-limit', message } }
  59. }
  60. }